190 lines
6.9 KiB
Swift
190 lines
6.9 KiB
Swift
import UIKit
|
|
|
|
/// 负责管理文本选区、长按交互和菜单定位。
|
|
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
|
private var isSelectionFromInteraction = false
|
|
private var selectionAnchorPoint: CGPoint?
|
|
private var selectionMenuAnchorRect: CGRect?
|
|
|
|
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
|
|
|
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool {
|
|
overlayView.selectionRange?.length ?? 0 > 0
|
|
}
|
|
|
|
func clearSelection(
|
|
textView: UITextView,
|
|
overlayView: RDEPUBSelectionOverlayView,
|
|
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
|
|
) {
|
|
textView.selectedRange = NSRange(location: 0, length: 0)
|
|
overlayView.clearSelection()
|
|
backgroundOverlayView?.clearSelection()
|
|
selectionAnchorPoint = nil
|
|
selectionMenuAnchorRect = nil
|
|
isSelectionFromInteraction = false
|
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
|
onSelectionChanged?(nil)
|
|
}
|
|
|
|
func handleLongPress(
|
|
_ gesture: UILongPressGestureRecognizer,
|
|
page: RDEPUBTextPage?,
|
|
overlayView: RDEPUBSelectionOverlayView,
|
|
interactionController: RDEPUBPageInteractionController
|
|
) {
|
|
let point = gesture.location(in: overlayView)
|
|
|
|
switch gesture.state {
|
|
case .began:
|
|
selectionAnchorPoint = point
|
|
isSelectionFromInteraction = true
|
|
handleSelectionFromInteraction(
|
|
point: point,
|
|
anchorPoint: nil,
|
|
page: page,
|
|
overlayView: overlayView,
|
|
interactionController: interactionController
|
|
)
|
|
|
|
case .changed:
|
|
guard let anchor = selectionAnchorPoint else { return }
|
|
handleSelectionFromInteraction(
|
|
point: point,
|
|
anchorPoint: anchor,
|
|
page: page,
|
|
overlayView: overlayView,
|
|
interactionController: interactionController
|
|
)
|
|
|
|
case .ended:
|
|
isSelectionFromInteraction = false
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func handleTap(
|
|
textView: UITextView,
|
|
overlayView: RDEPUBSelectionOverlayView,
|
|
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
|
|
) {
|
|
clearSelection(
|
|
textView: textView,
|
|
overlayView: overlayView,
|
|
backgroundOverlayView: backgroundOverlayView
|
|
)
|
|
}
|
|
|
|
func showSelectionMenuIfNeeded(
|
|
in hostView: UIView,
|
|
overlayView: RDEPUBSelectionOverlayView,
|
|
interactionController: RDEPUBPageInteractionController,
|
|
copyAction: Selector,
|
|
highlightAction: Selector,
|
|
annotateAction: Selector
|
|
) {
|
|
guard overlayView.selectionRange?.length ?? 0 > 0,
|
|
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
|
|
return
|
|
}
|
|
|
|
hostView.becomeFirstResponder()
|
|
let menuRect = overlayView.convert(anchorRect, to: hostView)
|
|
let menuController = UIMenuController.shared
|
|
menuController.menuItems = [
|
|
UIMenuItem(title: "拷贝", action: copyAction),
|
|
UIMenuItem(title: "高亮", action: highlightAction),
|
|
UIMenuItem(title: "批注", action: annotateAction)
|
|
]
|
|
menuController.setTargetRect(menuRect, in: hostView)
|
|
menuController.setMenuVisible(true, animated: true)
|
|
}
|
|
|
|
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
|
guard !isSelectionFromInteraction else { return }
|
|
guard let page else {
|
|
onSelectionChanged?(nil)
|
|
return
|
|
}
|
|
|
|
let selectedRange = textView.selectedRange
|
|
guard selectedRange.location != NSNotFound,
|
|
selectedRange.length > 0,
|
|
let attributedText = textView.attributedText else {
|
|
onSelectionChanged?(nil)
|
|
return
|
|
}
|
|
|
|
let source = attributedText.string as NSString
|
|
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !selectedText.isEmpty else {
|
|
onSelectionChanged?(nil)
|
|
return
|
|
}
|
|
|
|
let globalStart = page.pageStartOffset + selectedRange.location
|
|
let globalEnd = globalStart + selectedRange.length
|
|
let totalLength = max(page.chapterContent.length - 1, 1)
|
|
let selection = RDEPUBSelection(
|
|
location: RDEPUBLocation(
|
|
href: page.href,
|
|
progression: Double(globalStart) / Double(totalLength),
|
|
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
|
fragment: nil
|
|
),
|
|
text: selectedText,
|
|
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
|
)
|
|
onSelectionChanged?(selection)
|
|
}
|
|
|
|
private func handleSelectionFromInteraction(
|
|
point: CGPoint,
|
|
anchorPoint: CGPoint?,
|
|
page: RDEPUBTextPage?,
|
|
overlayView: RDEPUBSelectionOverlayView,
|
|
interactionController: RDEPUBPageInteractionController
|
|
) {
|
|
guard let page else { return }
|
|
|
|
let range: NSRange?
|
|
if let anchorPoint {
|
|
range = interactionController.selectionRange(from: anchorPoint, to: point)
|
|
} else if let idx = interactionController.characterIndex(at: point) {
|
|
range = NSRange(location: idx, length: 1)
|
|
} else {
|
|
range = nil
|
|
}
|
|
|
|
guard let range else { return }
|
|
let rects = interactionController.selectionRects(for: range)
|
|
overlayView.updateSelection(absoluteRange: range, rects: rects)
|
|
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
|
|
onSelectionChanged?(makeSelection(from: range, page: page))
|
|
}
|
|
|
|
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
|
let source = page.chapterContent.string as NSString
|
|
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !selectedText.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let chapterLength = max(page.chapterContent.length - 1, 1)
|
|
let chapterStart = max(range.location, 0)
|
|
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
|
|
return RDEPUBSelection(
|
|
location: RDEPUBLocation(
|
|
href: page.href,
|
|
progression: Double(chapterStart) / Double(chapterLength),
|
|
lastProgression: Double(chapterEnd) / Double(chapterLength),
|
|
fragment: nil
|
|
),
|
|
text: selectedText,
|
|
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
|
|
)
|
|
}
|
|
}
|