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 } }