import UIKit public final class RDEPUBReaderController: UIViewController { public weak var delegate: RDEPUBReaderDelegate? public var configuration: RDEPUBReaderConfiguration { didSet { // M-09: Apply all configuration side effects even before view is loaded, // but UI-dependent actions only after view is loaded. readerContext.configuration = configuration guard isViewLoaded else { return } applyWebViewDebugPolicy() persistReaderSettingsIfNeeded() 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() } 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 } 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() } let epubURL: URL let persistence: RDEPUBReaderPersistence? let dependencies: RDEPUBReaderDependencies let readerView = RDEpubReaderView() 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() /// Chapter-level shared display content/layouter for text pages /// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3). let textDisplayCache = RDEPUBChapterDisplayContentCache() 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() /// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial) var cachedTrialWallView: UIView? /// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本 /// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。 private var navigationBarHiddenBeforeReader: Bool? 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 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) 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) 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 ) } 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) if navigationBarHiddenBeforeReader == nil { navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden } navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.interactivePopGestureRecognizer?.delegate = self navigationController?.interactivePopGestureRecognizer?.isEnabled = true } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if let navigationBarHiddenBeforeReader { navigationController?.setNavigationBarHidden( navigationBarHiddenBeforeReader, animated: animated ) } } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startInitialLoadIfNeeded() RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self) } public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() runtime.viewportMonitor.viewDidLayoutSubviews() } public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() runtime.handleMemoryWarning() textDisplayCache.removeAll() } public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) runtime.viewportMonitor.viewWillTransition(with: coordinator) coordinator.animate(alongsideTransition: nil) { _ in RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))") } } 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() } } private func installSearchBarView() { guard searchBarView.superview == nil else { return } searchBarView.apply(theme: configuration.theme) readerView.addSubview(searchBarView) readerView.searchBarView = searchBarView NSLayoutConstraint.activate([ searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor), searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor), searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor), searchBarView.bottomAnchor.constraint(equalTo: readerView.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() } } 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 ) } 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 } }