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
@@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
addAnnotation(from: selection, style: .underline, color: color, note: note)
}
@discardableResult
@@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
note: String? = nil,
isAnnotationOnly: Bool = false
) -> RDEPUBHighlight? {
guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection
@@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
return nil
}
let newHighlight = RDEPUBHighlight(
var newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: style,
style: .underline,
color: color,
note: note
note: note,
isAnnotationOnly: isAnnotationOnly
)
let mergeCandidates = controller.activeHighlights.filter {
shouldMerge($0, with: newHighlight)
}
if !mergeCandidates.isEmpty {
newHighlight = mergedHighlight(newHighlight, with: mergeCandidates)
controller.activeHighlights.removeAll { candidate in
mergeCandidates.contains { $0.id == candidate.id }
}
}
let isDuplicate = controller.activeHighlights.contains { highlight in
highlight.location.href == newHighlight.location.href &&
highlight.location.fragment == newHighlight.location.fragment &&
highlight.text == newHighlight.text &&
highlight.rangeInfo == newHighlight.rangeInfo &&
highlight.style == newHighlight.style
highlight.style == newHighlight.style &&
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
}
guard !isDuplicate else {
return nil
@@ -219,25 +233,29 @@ 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)
})
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
switch action {
case .copy:
UIPasteboard.general.string = highlight.text
case .createUnderline:
guard highlight.isAnnotationOnly,
let controller,
let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
return
}
controller.activeHighlights[index].isAnnotationOnly = false
persistHighlightsAndRefreshContent()
case .deleteUnderline:
_ = removeHighlight(id: highlight.id)
case .annotate:
presentAnnotationNoteEditor(for: highlight)
case .deleteAnnotation:
if highlight.isAnnotationOnly {
_ = removeHighlight(id: highlight.id)
} else {
_ = 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?) {
@@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .highlight)
createAnnotation(from: selection, style: .underline)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
@@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
style: highlight.style,
style: .underline,
color: highlight.color,
note: highlight.note,
isAnnotationOnly: highlight.isAnnotationOnly,
createdAt: highlight.createdAt
)
}
private func shouldMerge(_ existing: RDEPUBHighlight, with incoming: RDEPUBHighlight) -> Bool {
guard existing.location.href == incoming.location.href,
let existingRange = RDEPUBTextOffsetRangeInfo.decode(from: existing.rangeInfo)?.nsRange,
let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo)?.nsRange else {
return false
}
// Only merge identical selections. Contained or partially-overlapping
// ranges remain independent so a reader can still manage each mark.
return existingRange.location == incomingRange.location
&& existingRange.length == incomingRange.length
}
private func mergedHighlight(
_ incoming: RDEPUBHighlight,
with existingHighlights: [RDEPUBHighlight]
) -> RDEPUBHighlight {
var result = incoming
let allHighlights = existingHighlights + [incoming]
let ranges = allHighlights.compactMap {
RDEPUBTextOffsetRangeInfo.decode(from: $0.rangeInfo)
}
if let first = ranges.first {
let start = ranges.map(\.start).min() ?? first.start
let end = ranges.map(\.end).max() ?? first.end
result.rangeInfo = RDEPUBTextOffsetRangeInfo(href: first.href, start: start, end: end).jsonString()
}
// The latest selection is exact whenever it encloses the merged range;
// otherwise keep every distinct excerpt rather than silently losing
// text that may later be copied from the marking list.
if let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo),
let mergedRange = RDEPUBTextOffsetRangeInfo.decode(from: result.rangeInfo),
incomingRange.start <= mergedRange.start,
incomingRange.end >= mergedRange.end {
result.text = incoming.text
} else {
result.text = allHighlights.map(\.text).reduce(into: [String]()) { texts, text in
if !texts.contains(text) { texts.append(text) }
}.joined(separator: " ")
}
let notes = allHighlights.compactMap(\.note).map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
let uniqueNotes = notes.reduce(into: [String]()) { values, note in
if !values.contains(note) { values.append(note) }
}
result.note = uniqueNotes.isEmpty ? nil : uniqueNotes.joined(separator: "\n\n")
return result
}
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
@@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
guard let controller else { return }
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .highlight)
})
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .underline)
})
alert.addAction(UIAlertAction(title: "", style: .default) { [weak self] _ in
alert.addAction(UIAlertAction(title: "", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.present(alert, animated: true)
}
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
_ = addAnnotation(from: selection, style: style, note: note)
private func createAnnotation(
from selection: RDEPUBSelection,
style: RDEPUBHighlightStyle,
note: String? = nil,
isAnnotationOnly: Bool = false
) {
_ = addAnnotation(
from: selection,
style: style,
note: note,
isAnnotationOnly: isAnnotationOnly
)
}
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
guard let controller else { return }
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
guard note != nil else { return }
self?.createAnnotation(
from: selection,
style: .highlight,
note: alert?.textFields?.first?.text
style: .underline,
note: note,
isAnnotationOnly: true
)
})
controller.present(alert, animated: true)
}
}
private func presentAnnotationNoteEditor(for highlight: RDEPUBHighlight) {
presentNoteEditor(quote: highlight.text, initialNote: highlight.note) { [weak self] note in
_ = self?.updateHighlightNote(id: highlight.id, note: note)
}
}
private func presentNoteEditor(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
guard let controller else { return }
let editor = RDEPUBAnnotationEditorViewController(
quote: quote,
initialNote: initialNote,
onSave: onSave
)
let navigationController = UINavigationController(rootViewController: editor)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
@@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
private unowned let context: RDEPUBReaderContext
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
init(context: RDEPUBReaderContext) {
self.context = context
}
@@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak controller] displayType in
controller?.updateConfiguration { $0.displayType = displayType }
}
@@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
}
let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet
navigationController.presentationController?.delegate = self
let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
settingsTransitionDelegate = transitionDelegate
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.view.backgroundColor = .clear
navigationController.view.isOpaque = false
navigationController.modalPresentationStyle = .custom
navigationController.transitioningDelegate = transitionDelegate
controller.present(navigationController, animated: true)
}
@@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
return candidate
}
}
private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
func presentationController(
forPresented presented: UIViewController,
presenting: UIViewController?,
source: UIViewController
) -> UIPresentationController? {
RDEPUBReaderSettingsPresentationController(
presentedViewController: presented,
presenting: presenting ?? source
)
}
}
private final class RDEPUBReaderSettingsPresentationController: UIPresentationController {
private let dimmingView = UIControl()
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerView else { return .zero }
let bounds = containerView.bounds
let isLandscape = bounds.width > bounds.height
if isLandscape {
let width = min(390, max(300, bounds.width * 0.32))
let height = min(340, bounds.height - 32)
return CGRect(
x: bounds.maxX - width - 16,
y: bounds.midY - height / 2,
width: width,
height: height
)
}
let height = min(350, max(280, bounds.height * 0.40))
return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
}
override func presentationTransitionWillBegin() {
guard let containerView else { return }
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18)
dimmingView.alpha = 0
dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside)
dimmingView.frame = containerView.bounds
containerView.insertSubview(dimmingView, at: 0)
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1
})
}
override func dismissalTransitionWillBegin() {
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
dimmingView.frame = containerView?.bounds ?? .zero
presentedView?.frame = frameOfPresentedViewInContainerView
}
override func dismissalTransitionDidEnd(_ completed: Bool) {
super.dismissalTransitionDidEnd(completed)
if completed { dimmingView.removeFromSuperview() }
}
@objc private func dismissPresentedController() {
if let navigationController = presentedViewController as? UINavigationController,
let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController {
settingsController.notifyDismissalIfNeeded()
}
presentedViewController.dismiss(animated: true)
}
}
@@ -163,7 +163,11 @@ final class RDEPUBReaderContext {
}
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
if publication?.requiresSinglePagePresentation == true {
preferences.fixedLayoutSpreadMode = .never
}
return preferences
}
func currentTextContentInsets(pageIndex: Int) -> UIEdgeInsets {
@@ -354,10 +354,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
@@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.presentAnnotationCreation()
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {