ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift

669 lines
26 KiB
Swift

import UIKit
final class RDEPUBReaderAnnotationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
return controller.activeBookmarks.first { $0.id == id }
}
func highlight(withID id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
return controller.activeHighlights.first { $0.id == id }
}
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
if let selection, !selection.isEmpty {
applySelectionState(.selected(selection))
} else {
applySelectionState(.idle)
}
}
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
context.selectionState = state
switch state {
case .idle:
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: nil)
case .selecting:
break
case .selected(let selection):
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
break
}
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .underline, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil,
isAnnotationOnly: Bool = false
) -> RDEPUBHighlight? {
guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection
guard let sourceSelection,
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
!scopedSelection.isEmpty else {
return nil
}
var newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: .underline,
color: color,
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.isAnnotationOnly == newHighlight.isAnnotationOnly
}
guard !isDuplicate else {
return nil
}
controller.activeHighlights.append(newHighlight)
persistHighlightsAndRefreshContent()
updateCurrentSelection(nil)
return newHighlight
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let scopedHighlight = scopedHighlight(highlight) else {
return nil
}
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
controller.activeHighlights[index] = scopedHighlight
} else {
controller.activeHighlights.append(scopedHighlight)
}
persistHighlightsAndRefreshContent()
return scopedHighlight
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeHighlights.remove(at: index)
persistHighlightsAndRefreshContent()
return removed
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
controller.activeHighlights[index].note = normalizedNote(note)
persistHighlightsAndRefreshContent()
return controller.activeHighlights[index]
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let highlight = highlight(withID: id) else {
return false
}
return navigate(to: highlight, animated: animated)
}
func removeAllHighlights() {
guard let controller else { return }
guard !controller.activeHighlights.isEmpty else { return }
controller.activeHighlights.removeAll()
persistHighlightsAndRefreshContent()
}
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
selection.location,
relativeToSpineIndex: spineIndex,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: selection.location.href,
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor,
cfi: selection.location.cfi,
lastCFI: selection.location.lastCFI,
rangeCFI: selection.location.rangeCFI
)
return RDEPUBSelection(
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return }
let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights,
theme: controller.configuration.theme,
sectionTitleProvider: { [weak self] highlight in
self?.titleForHighlight(highlight)
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
highlightsController?.dismiss(animated: true) {
_ = self?.navigate(to: highlight, animated: true)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
}
highlightsController.onDeleteHighlight = { [weak self] highlight in
_ = self?.controller?.removeHighlight(id: highlight.id)
}
let navigationController = UINavigationController(rootViewController: highlightsController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func presentAnnotationCreation() {
guard let controller else { return }
guard controller.configuration.allowsHighlights,
let currentSelection = controller.currentSelection else {
return
}
presentAnnotationActionSheet(for: currentSelection)
}
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)
}
}
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
case .copy:
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .underline)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
guard bookmark(matching: location) == nil else {
return nil
}
let newBookmark = RDEPUBBookmark(
bookIdentifier: controller.currentBookIdentifier,
location: location,
rangeInfo: controller.currentVisibleRangeInfo(),
chapterTitle: titleForBookmarkLocation(location),
note: normalizedBookmarkNote(note)
)
controller.activeBookmarks.append(newBookmark)
persistBookmarksAndRefreshChrome()
return newBookmark
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
if let existingBookmark = bookmark(matching: location) {
_ = removeBookmark(id: existingBookmark.id)
return nil
}
return addBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeBookmarks.remove(at: index)
persistBookmarksAndRefreshChrome()
return removed
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let bookmark = bookmark(withID: id) else {
return false
}
return controller.restoreReadingLocation(
bookmark.location,
animated: animated,
targetHighlightRangeInfo: bookmark.rangeInfo
)
}
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks,
theme: controller.configuration.theme
)
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
guard let controller = self?.controller else { return }
bookmarksController?.dismiss(animated: true) {
_ = controller.restoreReadingLocation(
bookmark.location,
animated: true,
targetHighlightRangeInfo: bookmark.rangeInfo
)
}
}
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
_ = self?.controller?.removeBookmark(id: bookmark.id)
}
let navigationController = UINavigationController(rootViewController: bookmarksController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
highlight.location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: highlight.location.href,
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor,
cfi: highlight.location.cfi,
lastCFI: highlight.location.lastCFI,
rangeCFI: highlight.location.rangeCFI
)
return RDEPUBHighlight(
id: highlight.id,
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
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 }
let navigationTarget = scopedHighlight(highlight) ?? highlight
return controller.restoreReadingLocation(
navigationTarget.location,
animated: animated,
targetHighlightRangeInfo: navigationTarget.rangeInfo
)
}
private func persistHighlightsAndRefreshContent() {
guard let controller else { return }
if let currentBookIdentifier = controller.currentBookIdentifier {
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
}
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
controller.updateReaderChrome()
refreshVisibleContentPreservingCurrentPage()
}
private func refreshVisibleContentPreservingCurrentPage() {
guard let controller else { return }
let currentPage = controller.readerView.currentPage
guard currentPage >= 0 else {
controller.refreshVisibleContentPreservingLocation()
return
}
controller.readerView.reloadData()
if controller.readerView.currentPage != currentPage {
controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
}
}
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: .underline)
})
alert.addAction(UIAlertAction(title: "注释", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = controller.bottomToolView
popover.sourceRect = controller.bottomToolView.bounds
}
controller.present(alert, animated: true)
}
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) {
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
guard note != nil else { return }
self?.createAnnotation(
from: selection,
style: .underline,
note: note,
isAnnotationOnly: 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? {
guard let controller else { return nil }
guard let publication = controller.publication,
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return nil
}
return controller.flattenedTableOfContents.first { item in
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
}?.title
}
private func normalizedNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
guard let controller else { return nil }
if let currentLocation = controller.currentVisibleLocation(),
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
return controller.currentTableOfContentsItem?.title
}
return controller.flattenedTableOfContents.last { item in
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
}?.title
}
private func persistBookmarksAndRefreshChrome() {
guard let controller else { return }
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
controller.updateReaderChrome()
}
func currentBookmark() -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
return bookmark(matching: location)
}
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location else { return nil }
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
}
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
guard let controller else { return false }
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
return false
}
if let bookmarkCFI = bookmark.location.cfi,
let locationCFI = location.cfi {
return bookmarkCFI == locationCFI
}
if let bookmarkAnchor = bookmark.location.rangeAnchor,
let locationAnchor = location.rangeAnchor {
return bookmarkAnchor == locationAnchor
}
if let bookmarkFragment = bookmark.location.fragment,
let locationFragment = location.fragment {
return bookmarkFragment == locationFragment
}
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
return progressionDelta <= threshold
}
private func bookmarkHref(for location: RDEPUBLocation) -> String {
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
}
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
let rawHref = href.components(separatedBy: "#").first ?? href
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
}
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
guard let controller else { return nil }
guard let location else { return nil }
guard let publication = controller.publication else {
return RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
}
private func normalizedBookmarkNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}