feat: improve epub reader controls and annotations

This commit is contained in:
shen
2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions
@@ -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(_:)))
])
}