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:
@@ -97,16 +97,28 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||
guard let globalSearchState = searchState else { return nil }
|
||||
|
||||
let matches = globalSearchState.matches.filter { searchMatch in
|
||||
searchMatchBelongsToPage(searchMatch, page: page)
|
||||
let matches: [RDEPUBSearchMatch]
|
||||
let currentMatchIndex: Int?
|
||||
|
||||
if let chapterData = chapterData(for: page),
|
||||
let resolvedState = resolvedSearchState(
|
||||
for: page,
|
||||
chapterData: chapterData,
|
||||
globalSearchState: globalSearchState
|
||||
) {
|
||||
matches = resolvedState.matches
|
||||
currentMatchIndex = resolvedState.currentMatchIndex
|
||||
} else {
|
||||
matches = globalSearchState.matches.filter { searchMatch in
|
||||
searchMatchBelongsToPage(searchMatch, page: page)
|
||||
}
|
||||
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||
matches.firstIndex(of: currentMatch)
|
||||
}
|
||||
}
|
||||
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||
matches.firstIndex(of: currentMatch)
|
||||
}
|
||||
return RDEPUBSearchState(
|
||||
keyword: globalSearchState.keyword,
|
||||
matches: matches,
|
||||
@@ -114,6 +126,126 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
private func resolvedSearchState(
|
||||
for page: RDEPUBTextPage,
|
||||
chapterData: RDEPUBChapterData,
|
||||
globalSearchState: RDEPUBSearchState
|
||||
) -> RDEPUBSearchState? {
|
||||
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
let normalizedHref = normalizedPageHref(for: page)
|
||||
let exactMatches = exactChapterSearchMatches(
|
||||
in: chapterData,
|
||||
keyword: normalizedKeyword,
|
||||
normalizedHref: normalizedHref
|
||||
)
|
||||
let pageMatches = exactMatches.filter { match in
|
||||
guard let rangeLocation = match.rangeLocation else { return false }
|
||||
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
|
||||
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
|
||||
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
|
||||
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
|
||||
}
|
||||
|
||||
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
return RDEPUBSearchState(
|
||||
keyword: globalSearchState.keyword,
|
||||
matches: pageMatches,
|
||||
currentMatchIndex: currentMatchIndex
|
||||
)
|
||||
}
|
||||
|
||||
private func exactChapterSearchMatches(
|
||||
in chapterData: RDEPUBChapterData,
|
||||
keyword: String,
|
||||
normalizedHref: String
|
||||
) -> [RDEPUBSearchMatch] {
|
||||
let source = chapterData.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return [] }
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: max(foundRange.length, 1),
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
return chapterData
|
||||
}
|
||||
|
||||
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
|
||||
}
|
||||
|
||||
private func makeChapterData(
|
||||
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||
chapterIndex: Int
|
||||
) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
href: runtimeChapter.href,
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
|
||||
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href),
|
||||
|
||||
Reference in New Issue
Block a user