- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
579 lines
22 KiB
Swift
579 lines
22 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: .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
|
|
}
|
|
}
|