feat: improve epub reader controls and annotations
This commit is contained in:
+133
-43
@@ -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? {
|
||||
|
||||
+83
-5
@@ -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 {
|
||||
|
||||
-4
@@ -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?) {
|
||||
|
||||
Reference in New Issue
Block a user