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: .highlight, color: color, note: note) } @discardableResult func addAnnotation( from selection: RDEPUBSelection? = nil, style: RDEPUBHighlightStyle, color: String = "#F8E16C", note: String? = nil ) -> 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 } let newHighlight = RDEPUBHighlight( bookIdentifier: controller.currentBookIdentifier, location: scopedSelection.location, text: scopedSelection.text, rangeInfo: scopedSelection.rangeInfo, style: style, color: color, note: note ) 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 } 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 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) }) } 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?) { guard let selection else { return } switch action { case .copy: UIPasteboard.general.string = selection.text updateCurrentSelection(nil) case .highlight: createAnnotation(from: selection, style: .highlight) 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: highlight.style, color: highlight.color, note: highlight.note, createdAt: highlight.createdAt ) } @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: .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 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) { _ = addAnnotation(from: selection, style: style, note: note) } 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 self?.createAnnotation( from: selection, style: .highlight, note: alert?.textFields?.first?.text ) }) controller.present(alert, 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 } }