refactor: split reader architecture and chrome handling
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
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?) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
controller.bottomToolView.setAddHighlightEnabled(
|
||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||||
)
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||||
}
|
||||
|
||||
@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 controller else { return false }
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return controller.restoreReadingLocation(highlight.location, 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
|
||||
)
|
||||
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
|
||||
guard let controller = self?.controller else { return }
|
||||
highlightsController?.dismiss(animated: true) {
|
||||
controller.go(to: highlight.location)
|
||||
}
|
||||
}
|
||||
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 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,
|
||||
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)
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
|
||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private func persistHighlightsAndRefreshContent() {
|
||||
guard let controller else { return }
|
||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
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)
|
||||
updateBookmarkChrome()
|
||||
}
|
||||
|
||||
private 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 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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedBookmarkNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
|
||||
setupReaderView(readerView, in: controller.view)
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
}
|
||||
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
|
||||
let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
|
||||
if let id = context.currentBookIdentifier {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
readerView.dataSource = context.controller as? RDReaderDataSource
|
||||
readerView.delegate = context.controller as? RDReaderDelegate
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(readerView)
|
||||
NSLayoutConstraint.activate([
|
||||
readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
|
||||
])
|
||||
|
||||
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
|
||||
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
|
||||
context.controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(loadingIndicator)
|
||||
NSLayoutConstraint.activate([
|
||||
loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
|
||||
errorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(errorLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
|
||||
errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
|
||||
errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderChromeCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
print("[Debug] onBack fired, controller: \(String(describing: self?.controller))")
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
_ = self?.context.runtime?.toggleBookmark()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
self?.presentTableOfContents()
|
||||
}
|
||||
toolView.onShowBookmarks = { [weak self] in
|
||||
self?.context.runtime?.presentBookmarksManager()
|
||||
}
|
||||
toolView.onShowHighlights = { [weak self] in
|
||||
self?.context.runtime?.presentHighlightsManager()
|
||||
}
|
||||
toolView.onAddHighlight = { [weak self] in
|
||||
self?.context.runtime?.presentAnnotationCreation()
|
||||
}
|
||||
toolView.onShowSettings = { [weak self] in
|
||||
self?.presentSettings()
|
||||
}
|
||||
return toolView
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
controller.topToolView.setTitle(
|
||||
controller.title
|
||||
?? controller.parser?.metadata.title
|
||||
?? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
)
|
||||
controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil)
|
||||
controller.bottomToolView.apply(theme: controller.configuration.theme)
|
||||
controller.bottomToolView.updateVisibility(
|
||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||
allowsHighlights: controller.configuration.allowsHighlights,
|
||||
showsSettingsPanel: controller.configuration.showsSettingsPanel
|
||||
)
|
||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
||||
controller.bottomToolView.setAddHighlightEnabled(
|
||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||||
)
|
||||
controller.bottomToolView.setHighlightsEnabled(
|
||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
||||
)
|
||||
controller.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
let settingsController = RDEPUBReaderSettingsViewController(
|
||||
configuration: controller.configuration,
|
||||
brightness: controller.currentBrightness
|
||||
)
|
||||
settingsController.onBrightnessChange = { [weak controller] brightness in
|
||||
controller?.setScreenBrightness(brightness)
|
||||
}
|
||||
settingsController.onFontSizeChange = { [weak controller] fontSize in
|
||||
controller?.updateConfiguration { $0.fontSize = fontSize }
|
||||
}
|
||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
||||
}
|
||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||
controller?.updateConfiguration { $0.displayType = displayType }
|
||||
}
|
||||
settingsController.onThemeChange = { [weak controller] theme in
|
||||
controller?.updateConfiguration { $0.theme = theme }
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
let items = controller.flattenedTableOfContents
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let chapterController = RDEPUBReaderChapterListController(
|
||||
items: items,
|
||||
currentItem: controller.currentTableOfContentsItem,
|
||||
theme: controller.configuration.theme
|
||||
)
|
||||
chapterController.onSelectItem = { [weak controller] item in
|
||||
guard let controller else { return }
|
||||
chapterController.dismiss(animated: true) {
|
||||
_ = controller.go(toTableOfContentsItem: item, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: chapterController)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
}
|
||||
|
||||
private func close(_ controller: UIViewController) {
|
||||
let target = closestDismissTarget(from: controller)
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.viewControllers.first !== target {
|
||||
navigationController.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if let navigationController = target.navigationController,
|
||||
navigationController.presentingViewController != nil {
|
||||
navigationController.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if target.presentingViewController != nil {
|
||||
target.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
controller.dismiss(animated: true)
|
||||
}
|
||||
|
||||
private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
|
||||
var candidate: UIViewController = controller
|
||||
var current = controller.parent
|
||||
while let parent = current {
|
||||
if let navigationController = parent.navigationController,
|
||||
navigationController.viewControllers.contains(parent) {
|
||||
return parent
|
||||
}
|
||||
if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
|
||||
return parent
|
||||
}
|
||||
candidate = parent
|
||||
current = parent.parent
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import UIKit
|
||||
|
||||
/// 阅读器共享状态中心:所有 coordinator 通过 context 访问业务状态和便捷方法。
|
||||
///
|
||||
/// context 持有:
|
||||
/// - 业务状态(parser、publication、textBook、pages 等)
|
||||
/// - UI 配置(configuration、brightness)
|
||||
/// - 持久化策略(persistence)
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
// MARK: - 引用
|
||||
|
||||
weak var controller: RDEPUBReaderController?
|
||||
weak var readerView: RDReaderView?
|
||||
var dependencies: RDEPUBReaderDependencies = .live
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
// MARK: - 业务状态
|
||||
|
||||
var parser: RDEPUBParser?
|
||||
var publication: RDEPUBPublication?
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
var textBook: RDEPUBTextBook?
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
var currentBookIdentifier: String?
|
||||
var paginationToken = UUID()
|
||||
var paginator: RDEPUBPaginator?
|
||||
var searchState: RDEPUBSearchState?
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
var currentSelection: RDEPUBSelection?
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
var isRepaginating: Bool = false
|
||||
var didStartInitialLoad: Bool = false
|
||||
var isExternalTextBook: Bool = false
|
||||
var textFileURL: URL?
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
self.readerView = controller.readerView
|
||||
}
|
||||
|
||||
// MARK: - 便捷方法
|
||||
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
let containerSize = readerView?.bounds.size ?? .zero
|
||||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||||
let resolvedSize = containerSize == .zero ? viewSize : containerSize
|
||||
return RDEPUBNavigatorLayoutContext(
|
||||
containerSize: resolvedSize,
|
||||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||||
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
|
||||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||||
reflowableContentInsets: configuration.reflowableContentInsets
|
||||
)
|
||||
}
|
||||
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
}
|
||||
|
||||
func currentTextPageSize() -> CGSize {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
if let readerView, let pageNum {
|
||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||||
return resolvedSize
|
||||
}
|
||||
}
|
||||
return currentLayoutContext().viewportSize
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
frameHeight: max(pageSize.height, 1),
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
avoidOrphans: true,
|
||||
avoidWidows: true,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
hyphenation: true,
|
||||
imageMaxHeightRatio: 0.85,
|
||||
fallbackViewportSize: dependencies.environment.fallbackViewportSize
|
||||
)
|
||||
}
|
||||
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
var currentBrightness: CGFloat {
|
||||
get { dependencies.environment.currentBrightness }
|
||||
set { dependencies.environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
controller?.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let currentBookIdentifier else { return nil }
|
||||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let currentBookIdentifier else { return }
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||||
return textBook.chapters.lazy
|
||||
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
func showLoading() {
|
||||
controller?.showLoading()
|
||||
}
|
||||
|
||||
func hideLoading() {
|
||||
controller?.hideLoading()
|
||||
}
|
||||
|
||||
func handle(error: Error) {
|
||||
controller?.handle(error: error)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
controller?.updateReaderChrome()
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
controller?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
controller?.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
controller?.updateBookmarkChrome()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import UIKit
|
||||
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
var currentBrightness: CGFloat { get set }
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
public var currentBrightness: CGFloat {
|
||||
get { CGFloat(UIScreen.main.brightness) }
|
||||
set { UIScreen.main.brightness = newValue }
|
||||
}
|
||||
|
||||
public var fallbackViewportSize: CGSize {
|
||||
UIScreen.main.bounds.size
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderDependencies {
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
makePaginator: @escaping () -> RDEPUBPaginator,
|
||||
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
|
||||
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
|
||||
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
) {
|
||||
self.environment = environment
|
||||
self.makeParser = makeParser
|
||||
self.makePaginator = makePaginator
|
||||
self.makeTextBookBuilder = makeTextBookBuilder
|
||||
self.makePlainTextBookBuilder = makePlainTextBookBuilder
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
}
|
||||
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
makeParser: { RDEPUBParser() },
|
||||
makePaginator: { RDEPUBPaginator() },
|
||||
makeTextBookBuilder: { renderer, cache, layoutConfig in
|
||||
RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
|
||||
},
|
||||
makePlainTextBookBuilder: { renderer, layoutConfig in
|
||||
RDPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
|
||||
},
|
||||
makeTextRenderer: { engine in
|
||||
switch engine {
|
||||
case .dtCoreText:
|
||||
return RDEPUBDTCoreTextRenderer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
!controller.didStartInitialLoad,
|
||||
readerView.bounds.width > 0,
|
||||
readerView.bounds.height > 0 else {
|
||||
return
|
||||
}
|
||||
controller.didStartInitialLoad = true
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
let loadToken = UUID()
|
||||
context.paginationToken = loadToken
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
let parser = self.context.makeParser()
|
||||
|
||||
do {
|
||||
try parser.parse(epubURL: controller.epubURL)
|
||||
let publication = parser.makePublication()
|
||||
let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
|
||||
let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
|
||||
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
|
||||
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.runtime?.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == loadToken else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.parser = parser
|
||||
context.publication = publication
|
||||
context.currentBookIdentifier = bookIdentifier
|
||||
context.activeBookmarks = bookmarks
|
||||
context.activeHighlights = highlights
|
||||
context.readingSession = RDEPUBReadingSession(publication: publication)
|
||||
context.readingSession?.transition(to: .loading)
|
||||
controller.title = parser.metadata.title.isEmpty
|
||||
? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||
: parser.metadata.title
|
||||
controller.applyReaderViewConfiguration()
|
||||
context.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didOpen: publication)
|
||||
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return false }
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
)
|
||||
} else {
|
||||
context.readingSession?.transition(to: .jumping)
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
return true
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
return nil
|
||||
}
|
||||
if context.textBook != nil, readerView.currentPage >= 0 {
|
||||
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
|
||||
}
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
return nil
|
||||
}
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
controller.delegate?.epubReader(controller, didUpdateLocation: location)
|
||||
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
|
||||
controller.updateBookmarkChrome()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
let publication = context.publication,
|
||||
let readingSession = context.readingSession else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.isRepaginating = true
|
||||
controller.errorLabel.isHidden = true
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
return
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
hostingView: controller.ensurePaginationHostView(),
|
||||
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
|
||||
) { [weak controller] pageCounts in
|
||||
guard let controller, self.context.paginationToken == token else { return }
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: pageCounts,
|
||||
preferences: controller.currentPreferences(),
|
||||
layoutContext: controller.currentLayoutContext()
|
||||
)
|
||||
self.context.paginator = nil
|
||||
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !textBook.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
context.handle(error: RDEPUBParserError.emptySpine)
|
||||
return
|
||||
}
|
||||
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
controller.isRepaginating = false
|
||||
controller.hideLoading()
|
||||
readerView.reloadData()
|
||||
if let targetLocation = restoreLocation {
|
||||
controller.restoreReadingLocation(targetLocation)
|
||||
} else {
|
||||
readerView.transitionToPage(pageNum: 0)
|
||||
context.readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
?? context.currentVisibleLocation()
|
||||
?? context.persistenceLocation()
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
readerView.reloadData()
|
||||
if let restoreLocation {
|
||||
_ = context.restoreReadingLocation(restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
let style = controller.currentTextRenderStyle()
|
||||
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
|
||||
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderRuntime {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
context.parser = nil
|
||||
context.publication = nil
|
||||
context.clearActiveSnapshot()
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
viewportMonitor.resetForReload()
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
readerView.reloadData()
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
pageNumber > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook != nil {
|
||||
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
return locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.bookmark(withID: id)
|
||||
}
|
||||
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.highlight(withID: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
style: RDEPUBHighlightStyle,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil
|
||||
) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
annotationCoordinator.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
func removeAllHighlights() {
|
||||
annotationCoordinator.removeAllHighlights()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.addBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
annotationCoordinator.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
func updateBookmarkChrome() {
|
||||
annotationCoordinator.updateBookmarkChrome()
|
||||
}
|
||||
|
||||
func presentBookmarksManager() {
|
||||
annotationCoordinator.presentBookmarksManager()
|
||||
}
|
||||
|
||||
func presentHighlightsManager() {
|
||||
annotationCoordinator.presentHighlightsManager()
|
||||
}
|
||||
|
||||
func presentAnnotationCreation() {
|
||||
annotationCoordinator.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
bookmarks: [RDEPUBBookmark],
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
loadCoordinator.applyParsedPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
restoreLocation: restoreLocation,
|
||||
bookmarks: bookmarks,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
|
||||
let matches = resolvedSearchMatches(for: normalizedKeyword)
|
||||
controller.searchState = RDEPUBSearchState(
|
||||
keyword: normalizedKeyword,
|
||||
matches: matches,
|
||||
currentMatchIndex: matches.isEmpty ? nil : 0
|
||||
)
|
||||
notifySearchStateChanged()
|
||||
|
||||
if matches.isEmpty {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
} else {
|
||||
_ = navigateToCurrentSearchMatch(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
notifySearchStateChanged()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
let publication = controller.publication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageHrefs: [String]
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
} else if publication.spine.indices.contains(page.spineIndex) {
|
||||
pageHrefs = [
|
||||
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
|
||||
?? publication.spine[page.spineIndex].href
|
||||
]
|
||||
} else {
|
||||
pageHrefs = []
|
||||
}
|
||||
|
||||
guard !pageHrefs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let currentMatch = searchState.currentMatch
|
||||
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
let resources = pageHrefs.map { href in
|
||||
let matchCount = searchState.matches.filter {
|
||||
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
|
||||
}.count
|
||||
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
|
||||
return RDEPUBSearchPresentationResource(
|
||||
href: href,
|
||||
matchCount: matchCount,
|
||||
activeLocalMatchIndex: activeLocalMatchIndex
|
||||
)
|
||||
}
|
||||
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
|
||||
}
|
||||
|
||||
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||
guard let controller else { return [] }
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
if let parser = controller.parser, let publication = controller.publication {
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func advanceSearch(by delta: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentIndex = searchState.currentMatchIndex ?? 0
|
||||
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
|
||||
searchState.currentMatchIndex = nextIndex
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
private func notifySearchStateChanged() {
|
||||
guard let controller else { return }
|
||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
|
||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
|
||||
}
|
||||
|
||||
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let searchMatch = controller.searchState?.currentMatch else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return false
|
||||
}
|
||||
|
||||
if let targetPageNumber = pageNumber(for: searchMatch),
|
||||
controller.readerView.currentPage == targetPageNumber - 1 {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return true
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
return controller.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let controller else { return nil }
|
||||
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||
return pageNumber
|
||||
}
|
||||
|
||||
if let rangeLocation = searchMatch.rangeLocation,
|
||||
let page = chapterData.page(containing: rangeLocation) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
return textBook.pageNumber(
|
||||
for: location,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
return controller.readingSession?.pageIndex(
|
||||
for: location,
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
private var controller: RDEPUBReaderController? {
|
||||
context.controller
|
||||
}
|
||||
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
|
||||
if !controller.didStartInitialLoad {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
controller.startInitialLoadIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil || controller.isExternalTextBook else {
|
||||
lastAppliedViewportSignature = viewportSignature
|
||||
return
|
||||
}
|
||||
|
||||
guard !isWaitingForViewportTransitionCompletion else {
|
||||
return
|
||||
}
|
||||
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
isWaitingForViewportTransitionCompletion = true
|
||||
|
||||
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||
guard let self, let controller = self.controller else { return }
|
||||
self.isWaitingForViewportTransitionCompletion = false
|
||||
controller.view.layoutIfNeeded()
|
||||
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
|
||||
}
|
||||
}
|
||||
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
pendingPresentationRestoreLocation = nil
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleViewportChangeIfNeeded(reason: pendingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
|
||||
let insets = controller.view.safeAreaInsets
|
||||
return RDEPUBViewportSignature(
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
safeTop: insets.top,
|
||||
safeLeft: insets.left,
|
||||
safeBottom: insets.bottom,
|
||||
safeRight: insets.right
|
||||
)
|
||||
}
|
||||
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad,
|
||||
let signature = viewportSignature ?? currentViewportSignature() else {
|
||||
return
|
||||
}
|
||||
|
||||
if controller.isRepaginating {
|
||||
pendingViewportChangeReason = reason
|
||||
return
|
||||
}
|
||||
|
||||
if let lastAppliedViewportSignature,
|
||||
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
|
||||
return
|
||||
}
|
||||
|
||||
lastAppliedViewportSignature = signature
|
||||
|
||||
if controller.isExternalTextBook {
|
||||
controller.rebuildExternalTextBook()
|
||||
return
|
||||
}
|
||||
|
||||
guard controller.publication != nil else { return }
|
||||
controller.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user