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? {