669 lines
26 KiB
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
|
|
}
|
|
}
|