feat: improve epub reader controls and annotations
This commit is contained in:
@@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
sourceRect: CGRect
|
||||
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||
highlight: RDEPUBHighlight
|
||||
)
|
||||
}
|
||||
|
||||
enum RDEPUBExistingHighlightMenuAction {
|
||||
case copy
|
||||
case createUnderline
|
||||
case deleteUnderline
|
||||
case annotate
|
||||
case deleteAnnotation
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentViewDelegate {
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
@@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
/// The underline whose compact text menu is currently visible. Keeping this
|
||||
/// separate from `currentSelection` means a marked paragraph is still fully
|
||||
/// selectable with a long press.
|
||||
private var menuHighlight: RDEPUBHighlight?
|
||||
|
||||
private var menuUnderlineHighlight: RDEPUBHighlight?
|
||||
|
||||
private var menuAnnotationHighlight: RDEPUBHighlight?
|
||||
|
||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||
// Stored as Any? to avoid @available on stored property restriction.
|
||||
private var _editMenuInteraction: Any?
|
||||
@@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
action == #selector(rd_copy(_:))
|
||||
if menuHighlight != nil {
|
||||
return action == #selector(rd_copy(_:))
|
||||
|| action == #selector(rd_highlight(_:))
|
||||
|| action == #selector(rd_deleteUnderline(_:))
|
||||
|| action == #selector(rd_annotate(_:))
|
||||
|| action == #selector(rd_deleteAnnotation(_:))
|
||||
}
|
||||
return action == #selector(rd_copy(_:))
|
||||
|| action == #selector(rd_highlight(_:))
|
||||
|| action == #selector(rd_annotate(_:))
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
if let highlight = menuHighlight {
|
||||
performHighlightMenuAction(.copy, highlight: highlight)
|
||||
return
|
||||
}
|
||||
performSelectionAction(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
if let highlight = menuAnnotationHighlight {
|
||||
performHighlightMenuAction(.createUnderline, highlight: highlight)
|
||||
return
|
||||
}
|
||||
performSelectionAction(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
if let highlight = menuHighlight {
|
||||
performHighlightMenuAction(.annotate, highlight: highlight)
|
||||
return
|
||||
}
|
||||
performSelectionAction(.annotate)
|
||||
}
|
||||
|
||||
@objc func rd_deleteUnderline(_ sender: Any?) {
|
||||
guard let highlight = menuUnderlineHighlight else { return }
|
||||
performHighlightMenuAction(.deleteUnderline, highlight: highlight)
|
||||
}
|
||||
|
||||
@objc func rd_deleteAnnotation(_ sender: Any?) {
|
||||
guard let highlight = menuAnnotationHighlight else { return }
|
||||
performHighlightMenuAction(.deleteAnnotation, highlight: highlight)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
@@ -855,50 +901,84 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let highlight = highlight(at: point),
|
||||
let highlights = highlights(at: point)
|
||||
guard let highlight = highlights.first,
|
||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||
return
|
||||
}
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
|
||||
showHighlightMenu(for: highlights, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) {
|
||||
// A short tap opens the compact system menu. Long-press selection is
|
||||
// intentionally handled by the separate recognizer, which the tap
|
||||
// recognizer already waits on, so text inside a marking remains
|
||||
// selectable.
|
||||
let underline = highlights.first { !$0.isAnnotationOnly }
|
||||
let annotation = highlights.first { $0.isAnnotationOnly }
|
||||
?? highlights.first { $0.hasNote }
|
||||
menuHighlight = annotation ?? underline
|
||||
menuUnderlineHighlight = underline
|
||||
menuAnnotationHighlight = annotation
|
||||
guard menuHighlight != nil else { return }
|
||||
becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))]
|
||||
if underline != nil {
|
||||
items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:))))
|
||||
} else if annotation != nil {
|
||||
items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))))
|
||||
}
|
||||
if let annotation, annotation.hasNote {
|
||||
items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
|
||||
} else if let underline, !underline.hasNote {
|
||||
items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:))))
|
||||
}
|
||||
menuController.menuItems = items
|
||||
menuController.setTargetRect(sourceRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
|
||||
private func performHighlightMenuAction(
|
||||
_ action: RDEPUBExistingHighlightMenuAction,
|
||||
highlight: RDEPUBHighlight
|
||||
) {
|
||||
menuHighlight = nil
|
||||
menuUnderlineHighlight = nil
|
||||
menuAnnotationHighlight = nil
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
menuHighlight = nil
|
||||
menuUnderlineHighlight = nil
|
||||
menuAnnotationHighlight = nil
|
||||
guard currentSelection != nil,
|
||||
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||
!targetRect.isEmpty else {
|
||||
return
|
||||
}
|
||||
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
becomeFirstResponder()
|
||||
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
||||
DispatchQueue.main.async { [weak self, weak interaction] in
|
||||
guard let self, self.currentSelection != nil else { return }
|
||||
interaction?.presentEditMenu(with: config)
|
||||
}
|
||||
} else {
|
||||
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(resolvedTargetRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
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(resolvedTargetRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
|
||||
private func hideSelectionMenu() {
|
||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||||
interaction.dismissMenu()
|
||||
} else {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
menuHighlight = nil
|
||||
menuUnderlineHighlight = nil
|
||||
menuAnnotationHighlight = nil
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
@@ -937,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
}
|
||||
|
||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||
guard let page = currentPage else { return nil }
|
||||
highlights(at: point).first
|
||||
}
|
||||
|
||||
private func highlights(at point: CGPoint) -> [RDEPUBHighlight] {
|
||||
guard let page = currentPage else { return [] }
|
||||
let matches = currentHighlights.filter { highlight in
|
||||
guard highlight.location.href == page.href,
|
||||
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||
@@ -952,11 +1036,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
||||
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
|
||||
if lhsRange != rhsRange {
|
||||
// In an overlap, the narrower marking is the one the reader
|
||||
// most likely meant to touch (e.g. underline A inside note B).
|
||||
return lhsRange < rhsRange
|
||||
}
|
||||
return lhsRange < rhsRange
|
||||
}.first
|
||||
return lhs.createdAt > rhs.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
||||
@@ -1133,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||
suggestedActions: [UIMenuElement]
|
||||
) -> UIMenu? {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user