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? {
|
||||
|
||||
Reference in New Issue
Block a user