ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
shen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

507 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
// MARK: -
/// EPUB
/// EPUBUI
/// EPUB URL TextBook
///
/// **** EPUBUI Core Parser Foundation
///
/// ****
/// - EPUB
/// -
/// -
/// - UI
/// -
///
/// **** init(epubURL:) viewDidLoad RDEPUBParser.parse paginatePublication readerView.reloadData restoreLocation
// MARK: -
public final class RDEPUBReaderController: UIViewController {
// MARK: -
///
public weak var delegate: RDEPUBReaderDelegate?
///
public var configuration: RDEPUBReaderConfiguration {
didSet {
readerContext.configuration = configuration
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
guard isViewLoaded else { return }
let oldConfiguration = oldValue
applyReaderViewConfiguration()
if isExternalTextBook {
if requiresRepagination(from: oldConfiguration, to: configuration) {
rebuildExternalTextBook()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
return
}
guard publication != nil else { return }
if requiresRepagination(from: oldConfiguration, to: configuration) {
repaginatePreservingCurrentLocation()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
}
}
///
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
/// 1 nil
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
/// /
public internal(set) var currentSelection: RDEPUBSelection? {
get { readerContext.currentSelection }
set { readerContext.currentSelection = newValue }
}
///
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
///
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
/// WXRead WRBookmark
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
///
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
///
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
// MARK: -
/// EPUB URL
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .darkGray
label.isHidden = true
return label
}()
let paginationHostView = UIView()
lazy var readerContext = RDEPUBReaderContext(controller: self)
var parser: RDEPUBParser? {
get { readerContext.parser }
set { readerContext.parser = newValue }
}
var publication: RDEPUBPublication? {
get { readerContext.publication }
set { readerContext.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { readerContext.readingSession }
set { readerContext.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { readerContext.textBook }
set { readerContext.textBook = newValue }
}
var activePages: [EPUBPage] {
readerContext.activePages
}
var activeChapters: [EPUBChapterInfo] {
readerContext.activeChapters
}
var activeBookmarks: [RDEPUBBookmark] {
get { readerContext.activeBookmarks }
set { readerContext.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { readerContext.activeHighlights }
set { readerContext.activeHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
///
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
}
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
var currentBrightness: CGFloat {
get { readerContext.currentBrightness }
set { readerContext.currentBrightness = newValue }
}
var didStartInitialLoad: Bool {
get { readerContext.didStartInitialLoad }
set { readerContext.didStartInitialLoad = newValue }
}
var isRepaginating: Bool {
get { readerContext.isRepaginating }
set { readerContext.isRepaginating = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { readerContext.lastTextPaginationPageSize }
set { readerContext.lastTextPaginationPageSize = newValue }
}
var isReconcilingTextPaginationSize = false
var paginationToken: UUID {
get { readerContext.paginationToken }
set { readerContext.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { readerContext.paginator }
set { readerContext.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
}
var isExternalTextBook: Bool {
get { readerContext.isExternalTextBook }
set { readerContext.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { readerContext.textFileURL }
set { readerContext.textFileURL = newValue }
}
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
// MARK: -
/// 使 EPUB URL
///
/// - Parameters:
/// - epubURL: EPUB URL
/// - configuration: 使 .default
/// - persistence: 使 UserDefaults
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
self.epubURL = epubURL
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
self.persistence = persistence
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
// Sync context after super.init
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
readerContext.persistence = persistence
self.currentBrightness = brightness
applyWebViewDebugPolicy()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
/// 使 TextBook EPUB
/// TXT TextBook
/// - Parameters:
/// - textBook: TextBook
/// - bookIdentifier:
/// - title:
/// - textFileURL: URL TextBook
/// - configuration:
/// - persistence:
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
title: String,
textFileURL: URL? = nil,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
self.init(
epubURL: URL(string: "about:blank")!,
configuration: resolvedConfig,
persistence: persistence,
dependencies: dependencies
)
self.isExternalTextBook = true
self.textBook = textBook
self.textFileURL = textFileURL
self.currentBookIdentifier = bookIdentifier
self.title = title
self.didStartInitialLoad = true
}
public override func viewDidLoad() {
super.viewDidLoad()
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
runtime.viewportMonitor.viewDidLayoutSubviews()
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
}
// MARK: -
///
/// `handleToolViewVisibilityChanged`
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if readerView.isShowToolView {
installSearchBarView()
}
}
/// readerView
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
let bottomAnchor = bottomToolView.superview == nil
? readerView.bottomAnchor
: bottomToolView.topAnchor
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
searchBarView.alpha = 0
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
} else {
searchBarView.showNoResults()
}
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
///
/// - Parameter clearSearch:
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
///
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
searchBarView.updateResults(
sections: searchResultSections(for: searchState),
keyword: searchState.keyword,
currentMatchIndex: searchState.currentMatchIndex
)
}
/// RDReaderView
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
installSearchBarView()
} else if searchState != nil {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}