feat: improve native text selection and annotation actions
This commit is contained in:
parent
70133e4e6e
commit
1c6108061c
Binary file not shown.
@ -11,7 +11,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
|
|
||||||
let content = app.textViews[IDs.readerSelectionText].firstMatch
|
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||||
|
|
||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
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.launchAndOpenSampleBook(pageNumber: 2)
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
|
|
||||||
let content = app.textViews[IDs.readerSelectionText].firstMatch
|
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||||
|
|
||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
||||||
|
|||||||
@ -96,6 +96,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
contentView.clearSelection()
|
contentView.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func textContentView(
|
||||||
|
_ contentView: RDEPUBTextContentView,
|
||||||
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||||
|
sourceRect: CGRect
|
||||||
|
) {
|
||||||
|
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 根据 EPUB 位置计算对应的页码
|
/// 根据 EPUB 位置计算对应的页码
|
||||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||||
|
|||||||
@ -225,6 +225,28 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
presentAnnotationActionSheet(for: currentSelection)
|
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?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
guard let selection else { return }
|
guard let selection else { return }
|
||||||
|
|||||||
@ -182,6 +182,10 @@ final class RDEPUBReaderRuntime {
|
|||||||
annotationCoordinator.presentAnnotationCreation()
|
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?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,11 @@ final class RDEPUBPageInteractionController {
|
|||||||
)
|
)
|
||||||
let idx = dtLine.stringIndex(forPosition: relativePoint)
|
let idx = dtLine.stringIndex(forPosition: relativePoint)
|
||||||
guard idx != NSNotFound, idx >= 0 else { return nil }
|
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
|
#else
|
||||||
return nil
|
return nil
|
||||||
#endif
|
#endif
|
||||||
@ -81,9 +85,10 @@ final class RDEPUBPageInteractionController {
|
|||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
|
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 endIdx = overlap.location + overlap.length
|
||||||
let endX = dtLine.offset(forStringIndex: endIdx)
|
let endX = dtLine.offset(forStringIndex: endIdx - pageOffset)
|
||||||
#else
|
#else
|
||||||
let startX: CGFloat = 0
|
let startX: CGFloat = 0
|
||||||
let endX: CGFloat = line.frame.width
|
let endX: CGFloat = line.frame.width
|
||||||
@ -141,7 +146,7 @@ final class RDEPUBPageInteractionController {
|
|||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
|
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
|
#else
|
||||||
let offsetX: CGFloat = 0
|
let offsetX: CGFloat = 0
|
||||||
#endif
|
#endif
|
||||||
@ -192,11 +197,16 @@ final class RDEPUBPageInteractionController {
|
|||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
||||||
guard let dtLayoutFrame else { return nil }
|
guard let dtLayoutFrame, let snapshot else { return nil }
|
||||||
return dtLayoutFrame.lineContaining(UInt(range.location))
|
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
|
||||||
|
return dtLayoutFrame.lineContaining(UInt(localLocation))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private func pageOffset(for snapshot: RDEPUBPageLayoutSnapshot) -> Int {
|
||||||
|
snapshot.page.pageStartOffset
|
||||||
|
}
|
||||||
|
|
||||||
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
|
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
|
||||||
guard rects.count > 1 else { return rects }
|
guard rects.count > 1 else { return rects }
|
||||||
|
|
||||||
|
|||||||
@ -14,13 +14,17 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|||||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||||
selection: RDEPUBSelection?
|
selection: RDEPUBSelection?
|
||||||
)
|
)
|
||||||
|
func textContentView(
|
||||||
|
_ contentView: RDEPUBTextContentView,
|
||||||
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||||
|
sourceRect: CGRect
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 文本内容视图
|
// MARK: - 文本内容视图
|
||||||
|
|
||||||
/// EPUB 流式排版的文本内容视图
|
/// EPUB 流式排版的文本内容视图
|
||||||
/// 对齐 WXRead 架构:高亮通过 NSAttributedString 自定义属性注入,在统一 drawRect 中绘制。
|
/// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。
|
||||||
/// 保留 UITextView 用于 XCUITest 兼容和系统级文本选择。
|
|
||||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
@ -28,6 +32,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
private var currentPage: RDEPUBTextPage?
|
private var currentPage: RDEPUBTextPage?
|
||||||
private var currentSelection: RDEPUBSelection?
|
private var currentSelection: RDEPUBSelection?
|
||||||
private var menuSelection: RDEPUBSelection?
|
private var menuSelection: RDEPUBSelection?
|
||||||
|
private var currentHighlights: [RDEPUBHighlight] = []
|
||||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
@ -55,19 +60,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
return view
|
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 = {
|
private let coverImageView: UIImageView = {
|
||||||
let view = UIImageView()
|
let view = UIImageView()
|
||||||
view.contentMode = .scaleAspectFit
|
view.contentMode = .scaleAspectFit
|
||||||
@ -81,25 +73,24 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
return label
|
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 = {
|
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||||
let stack = UIStackView(arrangedSubviews: [
|
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||||
selectionMenuButton(title: "拷贝", menuAction: .copy),
|
gesture.isEnabled = false
|
||||||
selectionMenuButton(title: "高亮", menuAction: .highlight),
|
gesture.delegate = self
|
||||||
selectionMenuButton(title: "批注", menuAction: .annotate)
|
return gesture
|
||||||
])
|
}()
|
||||||
stack.axis = .horizontal
|
|
||||||
stack.alignment = .fill
|
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
||||||
stack.distribution = .fillEqually
|
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||||
stack.spacing = 1
|
gesture.delegate = self
|
||||||
stack.backgroundColor = UIColor(white: 0.12, alpha: 0.96)
|
return gesture
|
||||||
stack.layer.cornerRadius = 10
|
|
||||||
stack.layer.masksToBounds = true
|
|
||||||
stack.layer.zPosition = 100
|
|
||||||
stack.isHidden = true
|
|
||||||
stack.accessibilityIdentifier = "epub.reader.selection.menu"
|
|
||||||
return stack
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
@ -113,19 +104,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
addSubview(coreTextContentView)
|
addSubview(coreTextContentView)
|
||||||
#endif
|
#endif
|
||||||
addSubview(overlayView)
|
addSubview(overlayView)
|
||||||
addSubview(textView)
|
|
||||||
addSubview(pageNumberLabel)
|
addSubview(pageNumberLabel)
|
||||||
addSubview(selectionActionBar)
|
addGestureRecognizer(longPressGestureRecognizer)
|
||||||
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||||
|
|
||||||
textView.delegate = selectionController
|
|
||||||
selectionController.onSelectionChanged = { [weak self] selection in
|
selectionController.onSelectionChanged = { [weak self] selection in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.currentSelection = selection
|
self.currentSelection = selection
|
||||||
if let selection {
|
if let selection {
|
||||||
self.menuSelection = selection
|
self.menuSelection = selection
|
||||||
self.showSelectionActionBarIfNeeded()
|
|
||||||
} else {
|
} else {
|
||||||
self.hideSelectionActionBar()
|
self.hideSelectionMenu()
|
||||||
}
|
}
|
||||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||||
}
|
}
|
||||||
@ -169,7 +160,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
updateCoreTextLayoutFrameIfNeeded()
|
updateCoreTextLayoutFrameIfNeeded()
|
||||||
#endif
|
#endif
|
||||||
overlayView.frame = bounds.inset(by: contentInsets)
|
overlayView.frame = bounds.inset(by: contentInsets)
|
||||||
textView.frame = bounds.inset(by: contentInsets)
|
|
||||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||||
|
|
||||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||||
@ -179,7 +169,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
width: labelSize.width,
|
width: labelSize.width,
|
||||||
height: labelSize.height
|
height: labelSize.height
|
||||||
)
|
)
|
||||||
updateSelectionActionBarFrame()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Configure
|
// MARK: - Configure
|
||||||
@ -195,7 +184,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
currentPage = page
|
currentPage = page
|
||||||
currentSelection = nil
|
currentSelection = nil
|
||||||
menuSelection = nil
|
menuSelection = nil
|
||||||
selectionController.clearSelection()
|
currentHighlights = highlights
|
||||||
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = configuration.reflowableContentInsets
|
contentInsets = configuration.reflowableContentInsets
|
||||||
backgroundColor = configuration.theme.contentBackgroundColor
|
backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||||
@ -207,10 +197,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
textView.isHidden = true
|
|
||||||
textView.isUserInteractionEnabled = false
|
|
||||||
#endif
|
#endif
|
||||||
textView.attributedText = nil
|
|
||||||
delegate?.textContentView(self, didChangeSelection: nil)
|
delegate?.textContentView(self, didChangeSelection: nil)
|
||||||
setNeedsLayout()
|
setNeedsLayout()
|
||||||
return
|
return
|
||||||
@ -234,14 +221,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||||||
coreTextDisplayContent = displayContent
|
coreTextDisplayContent = displayContent
|
||||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||||
coreTextContentView.attributedDisplayContent = displayContent
|
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()
|
updateCoreTextLayoutFrameIfNeeded()
|
||||||
#else
|
#else
|
||||||
let selectionContent = normalizedPageContent(from: page)
|
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.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||||
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, 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
|
#endif
|
||||||
|
|
||||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||||
// 高亮/下划线已通过 attributed string 自定义属性注入,由 renderView 在 draw(_:) 中绘制
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||||
let (_, fgDecorations) = overlayView.buildDecorations(
|
|
||||||
page: page,
|
page: page,
|
||||||
highlights: [],
|
highlights: [],
|
||||||
searchState: searchState,
|
searchState: searchState,
|
||||||
interactionController: interactionController
|
interactionController: interactionController
|
||||||
)
|
)
|
||||||
|
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||||
overlayView.applyDecorations(fgDecorations)
|
overlayView.applyDecorations(fgDecorations)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@ -280,8 +258,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
func clearSelection() {
|
func clearSelection() {
|
||||||
currentSelection = nil
|
currentSelection = nil
|
||||||
menuSelection = nil
|
menuSelection = nil
|
||||||
selectionController.clearSelection()
|
panGestureRecognizer.isEnabled = false
|
||||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
overlayView.clearSelection()
|
overlayView.clearSelection()
|
||||||
backgroundOverlayView.clearSelection()
|
backgroundOverlayView.clearSelection()
|
||||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
@ -293,59 +271,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
let selection = currentSelection ?? menuSelection
|
let selection = currentSelection ?? menuSelection
|
||||||
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
||||||
menuSelection = nil
|
menuSelection = nil
|
||||||
hideSelectionActionBar()
|
hideSelectionMenu()
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cover Image
|
// MARK: - Cover Image
|
||||||
@ -364,10 +290,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
coreTextContentView.layoutFrame = nil
|
coreTextContentView.layoutFrame = nil
|
||||||
coreTextDisplayContent = nil
|
coreTextDisplayContent = nil
|
||||||
coreTextDisplayRange = nil
|
coreTextDisplayRange = nil
|
||||||
textView.isHidden = true
|
|
||||||
textView.isUserInteractionEnabled = false
|
|
||||||
#endif
|
#endif
|
||||||
textView.attributedText = nil
|
|
||||||
return true
|
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
|
// MARK: - Content Normalization
|
||||||
|
|
||||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||||
@ -588,6 +484,152 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||||
}
|
}
|
||||||
#endif
|
#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
|
// MARK: - UIColor Extension
|
||||||
|
|||||||
@ -1,49 +1,146 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 负责管理文本选区、UITextView 代理和菜单定位。
|
/// 负责管理文本选区和 CoreText 命中测试。
|
||||||
/// 对齐 WXRead 的选择模型:UITextView 处理原生选择,高亮通过 attributed string 属性绘制。
|
/// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。
|
||||||
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
final class RDEPUBTextSelectionController: NSObject {
|
||||||
|
|
||||||
private var isSelectionFromInteraction = false
|
private(set) var isSelecting = false
|
||||||
private var selectionMenuAnchorRect: CGRect?
|
private var selectionStartIndex: Int = NSNotFound
|
||||||
|
private var selectionEndIndex: Int = NSNotFound
|
||||||
|
|
||||||
/// 选区变化回调,通知视图层更新 UI
|
/// 选区变化回调,通知视图层更新 UI
|
||||||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||||||
/// 获取当前页面数据
|
/// 获取当前页面数据
|
||||||
var pageProvider: (() -> RDEPUBTextPage?)?
|
var pageProvider: (() -> RDEPUBTextPage?)?
|
||||||
|
|
||||||
// MARK: - UITextViewDelegate
|
var hasActiveSelection: Bool {
|
||||||
|
selectedAbsoluteRange != nil
|
||||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
||||||
textViewDidChangeSelection(textView, page: pageProvider?())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
var selectedAbsoluteRange: NSRange? {
|
||||||
guard !isSelectionFromInteraction else { return }
|
guard selectionStartIndex != NSNotFound,
|
||||||
guard let page else {
|
selectionEndIndex != NSNotFound else {
|
||||||
onSelectionChanged?(nil)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedRange = textView.selectedRange
|
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
|
||||||
guard selectedRange.location != NSNotFound,
|
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
|
||||||
selectedRange.length > 0,
|
}
|
||||||
let attributedText = textView.attributedText else {
|
|
||||||
onSelectionChanged?(nil)
|
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||||
return
|
guard absoluteRange.location != NSNotFound,
|
||||||
|
absoluteRange.length > 0,
|
||||||
|
NSMaxRange(absoluteRange) <= page.chapterContent.length else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let source = attributedText.string as NSString
|
let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
|
||||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
guard !selectedText.isEmpty else {
|
return nil
|
||||||
onSelectionChanged?(nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let globalStart = page.pageStartOffset + selectedRange.location
|
|
||||||
let globalEnd = globalStart + selectedRange.length
|
|
||||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
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(
|
location: RDEPUBLocation(
|
||||||
href: page.href,
|
href: page.href,
|
||||||
progression: Double(globalStart) / Double(totalLength),
|
progression: Double(globalStart) / Double(totalLength),
|
||||||
@ -53,45 +150,5 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
|||||||
text: selectedText,
|
text: selectedText,
|
||||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user