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 和状态管理
This commit is contained in:
@@ -30,6 +30,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
readerContext.configuration = configuration
|
||||
applyWebViewDebugPolicy()
|
||||
persistReaderSettingsIfNeeded()
|
||||
guard isViewLoaded else { return }
|
||||
let oldConfiguration = oldValue
|
||||
@@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
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:
|
||||
@@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
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()
|
||||
@@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
_ = 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)
|
||||
}
|
||||
@@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
readerView.addSubview(searchBarView)
|
||||
readerView.searchBarView = searchBarView
|
||||
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
||||
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, constant: topToolbarHeight),
|
||||
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.searchBarView.transform = .identity
|
||||
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
|
||||
@@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
isSearchBarVisible = false
|
||||
|
||||
searchBarView.textField.resignFirstResponder()
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
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.transform = .identity
|
||||
self.searchBarView.alpha = 1
|
||||
self.searchBarView.presentedView.transform = .identity
|
||||
self.readerView.searchBarView = nil
|
||||
}
|
||||
|
||||
@@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchBarView.showNoResults()
|
||||
return
|
||||
}
|
||||
if let index = searchState.currentMatchIndex {
|
||||
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
searchBarView.updateResults(
|
||||
sections: searchResultSections(for: searchState),
|
||||
keyword: searchState.keyword,
|
||||
currentMatchIndex: searchState.currentMatchIndex
|
||||
)
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||
@@ -417,3 +454,53 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user