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:
shen
2026-06-13 22:48:56 +08:00
parent 27e9b85ddb
commit 6f75b083f7
83 changed files with 4824 additions and 1879 deletions
@@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
//
} else {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
}
return nil
}
do {
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
return nil
}
}
// MARK: - BookPageMap
@@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
return true
}
/// renderSignature
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
isCacheComplete(keys: keys)
}
///
func removeAll() {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" {
try? fileManager.removeItem(at: fileURL)
removeFiles(matching: { _ in true })
}
///
func removeAll(forBookID bookID: String) {
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
removeFiles { $0.hasPrefix(bookPrefix + "__") }
}
///
func removeAll(forRenderSignature renderSignature: String) {
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
removeFiles { $0.contains(renderPrefix) }
}
///
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return (0, 0)
}
var count = 0
var totalBytes: Int64 = 0
for fileURL in files where fileURL.pathExtension == "json" {
count += 1
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalBytes += Int64(size)
}
}
return (count, totalBytes)
}
// MARK: - key ->
/// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.sha256Hex
return cacheDirectory.appendingPathComponent("\(digest).json")
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key)
let data = try? JSONEncoder().encode(summary)
try? data?.write(to: fileURL)
let tmpURL = fileURL.appendingPathExtension("tmp")
do {
let data = try JSONEncoder().encode(summary)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
#if DEBUG
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
#endif
try? fileManager.removeItem(at: tmpURL)
}
}
private func removeFiles(matching predicate: (String) -> Bool) {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
try? fileManager.removeItem(at: fileURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.sha256Hex.prefix(12).lowercased()
}
}
@@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error):
#if DEBUG
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
#endif
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
@@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
#if DEBUG
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
#endif
return
}
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
@@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
return store.chapterData(for: spineIndex)
}
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
#if DEBUG
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
#endif
currentSnapshot = snapshot
isApplyingSnapshot = true
onSnapshotChanged?(snapshot)
@@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
private func handle(error: Error) {
//
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
#endif
// loading
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
//
if self.currentSnapshot == nil {
#if DEBUG
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
#endif
}
}
}
@@ -1,111 +0,0 @@
import Foundation
struct RDEPUBLocationConverter {
// MARK: -
/// RDEPUBLocation -> RDEPUBChapterLocation
/// chapterLength
/// fallback
static func convert(
legacy location: RDEPUBLocation,
parser: RDEPUBParser,
publication: RDEPUBPublication,
chapterLengthProvider: ((Int) -> Int?)? = nil
) -> RDEPUBChapterLocation? {
// 1. href spineIndex
guard let spineItem = publication.spine.first(where: {
$0.href == location.href || $0.href.contains(location.href)
}) else { return nil }
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
// 2. fragmentID progression
if let fragmentID = location.fragment {
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: 0, // fragmentID chapterOffsetMap
fragmentID: fragmentID,
progressionInChapter: location.progression
)
}
// 3. chapterLength
if let provider = chapterLengthProvider,
let chapterLength = provider(spineIndex), chapterLength > 0 {
return convert(
legacy: location,
spineIndex: spineIndex,
chapterLength: chapterLength
)
}
// 4. Fallback
let estimatedOffset = Int(location.progression * 10000)
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: estimatedOffset,
fragmentID: nil,
progressionInChapter: location.progression,
schemaVersion: 1 //
)
}
///
static func convert(
legacy location: RDEPUBLocation,
spineIndex: Int,
chapterLength: Int
) -> RDEPUBChapterLocation? {
let offset = Int(location.progression * Double(chapterLength))
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: offset,
fragmentID: location.fragment,
progressionInChapter: location.progression,
schemaVersion: 2
)
}
/// RDEPUBRuntimeChapter
static func convert(
legacy location: RDEPUBLocation,
chapter: RDEPUBRuntimeChapter
) -> RDEPUBChapterLocation? {
// fragmentID
if let fragmentID = location.fragment,
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
return RDEPUBChapterLocation(
spineIndex: chapter.spineIndex,
chapterOffset: fragmentOffset,
fragmentID: fragmentID,
progressionInChapter: nil,
schemaVersion: 2
)
}
// progression +
let chapterLength = chapter.typesetAttributedString.length
return convert(
legacy: location,
spineIndex: chapter.spineIndex,
chapterLength: chapterLength
)
}
/// ->
static func toLegacy(
chapterLocation: RDEPUBChapterLocation,
href: String,
chapterLength: Int
) -> RDEPUBLocation {
let progression = chapterLength > 0
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
: 0
return RDEPUBLocation(
href: href,
progression: min(max(progression, 0), 1),
fragment: chapterLocation.fragmentID
)
}
}
@@ -17,12 +17,6 @@ final class RDEPUBPageCountCache {
}
}
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
lock.lock()
defer { lock.unlock() }
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
}
func remove(forSpineIndex spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
@@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
///
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
if let selection, !selection.isEmpty {
applySelectionState(.selected(selection))
} else {
applySelectionState(.idle)
}
}
///
/// `.selected` context chrome delegate
/// `.idle` chrome delegate
func applySelectionState(_ state: RDEPUBSelectionState) {
guard let controller else { return }
controller.currentSelection = selection?.isEmpty == false ? selection : nil
if controller.currentSelection != nil,
controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
context.selectionState = state
switch state {
case .idle:
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: nil)
case .selecting:
break
case .selected(let selection):
if controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: selection)
case .committingAction:
break
}
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
}
///
@@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
///
@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)
return navigate(to: highlight, animated: animated)
}
///
@@ -196,9 +215,8 @@ final class RDEPUBReaderAnnotationCoordinator {
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
guard let controller = self?.controller else { return }
highlightsController?.dismiss(animated: true) {
controller.go(to: highlight.location)
_ = self?.navigate(to: highlight, animated: true)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
@@ -371,6 +389,17 @@ final class RDEPUBReaderAnnotationCoordinator {
)
}
@discardableResult
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
guard let controller else { return false }
let navigationTarget = scopedHighlight(highlight) ?? highlight
return controller.restoreReadingLocation(
navigationTarget.location,
animated: animated,
targetHighlightRangeInfo: navigationTarget.rangeInfo
)
}
private func persistHighlightsAndRefreshContent() {
guard let controller else { return }
if let currentBookIdentifier = controller.currentBookIdentifier {
@@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
#if DEBUG
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
#endif
}
///
@@ -41,9 +43,13 @@ final class RDEPUBReaderAssemblyCoordinator {
}
if let textBook = controller.textBook {
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
#endif
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
#if DEBUG
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
#endif
} else {
runtime.finishPagination(restoreLocation: restoreLocation)
}
@@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
canToggleBookmark: controller.currentBookIdentifier != nil,
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
canShowBookmarks: !controller.activeBookmarks.isEmpty,
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil,
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
@@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
///
var currentSelection: RDEPUBSelection?
/// selectionState
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
///
var selectionState: RDEPUBSelectionState = .idle
// MARK: - controller
@@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
///
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
guard let controller = context.controller,
let readerView = context.readerView else { return false }
guard let targetPageNumber = controller.pageNumber(for: location) else {
@@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else if context.textBook == nil {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
bookIdentifier: context.currentBookIdentifier,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
} else {
context.readingSession?.transition(to: .jumping)
@@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
controller.showLoading()
let token = UUID()
context.paginationToken = token
#if DEBUG
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
#endif
if publication.readingProfile == .textReflowable {
#if DEBUG
print("[EPUB][Pagination] path=text-reflowable-on-demand")
#endif
paginateTextPublication(
parser: parser,
publication: publication,
@@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
}
if publication.layout == .fixed {
#if DEBUG
print("[EPUB][Pagination] path=fixed-layout")
#endif
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
@@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
}
let paginator = context.makePaginator()
#if DEBUG
print("[EPUB][Pagination] path=web-paginator")
#endif
context.paginator = paginator
paginator.calculate(
parser: parser,
@@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPrevious()
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
searchCoordinator.selectSearchMatch(at: index)
}
///
func clearSearch() {
searchCoordinator.clearSearch()
@@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
///
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
locationCoordinator.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
///
@@ -51,6 +51,21 @@ final class RDEPUBReaderSearchCoordinator {
advanceSearch(by: -1)
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState,
searchState.matches.indices.contains(index) else {
return false
}
searchState.currentMatchIndex = index
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
///
func clearSearch() {
guard let controller else { return }
@@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
}
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
}
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
return resolvedOnDemandSearchMatches(for: keyword)
}
if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
return []
}
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller,
let publication = controller.publication else {
return []
}
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
return []
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var matches: [RDEPUBSearchMatch] = []
for spineIndex in buildableSpineIndices {
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: controller.runtime.chapterRuntimeStore
) else {
continue
}
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
let source = chapter.typesetAttributedString.string as NSString
let fullLength = source.length
guard fullLength > 0 else { continue }
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, 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: foundRange.length,
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 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 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 advanceSearch(by delta: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
@@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let controller else { return nil }
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
if let exactPageNumber = exactPageNumber(
for: searchMatch,
in: chapterData,
keyword: controller.searchState?.keyword
) {
return exactPageNumber
}
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
@@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
bookIdentifier: controller.currentBookIdentifier
).map { $0 + 1 }
}
private func exactPageNumber(
for searchMatch: RDEPUBSearchMatch,
in chapterData: RDEPUBChapterData,
keyword: String?
) -> Int? {
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedKeyword.isEmpty else { return nil }
let source = chapterData.attributedContent.string as NSString
let fullLength = source.length
guard fullLength > 0 else { return nil }
var localMatchIndex = 0
var searchRange = NSRange(location: 0, length: fullLength)
while searchRange.length > 0 {
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
guard foundRange.location != NSNotFound else { break }
if localMatchIndex == searchMatch.localMatchIndex,
let page = chapterData.page(containing: foundRange.location) {
return page.absolutePageIndex + 1
}
localMatchIndex += 1
let nextLocation = foundRange.location + max(foundRange.length, 1)
if nextLocation >= fullLength {
break
}
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
}
return nil
}
}
@@ -0,0 +1,39 @@
// RDEPUBSelectionState.swift
//
// view controller
//
import Foundation
///
/// view/controller/coordinator `currentSelection != nil`
enum RDEPUBSelectionState: Equatable {
///
case idle
///
case selecting(anchor: Int)
///
case selected(RDEPUBSelection)
/// // idle
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
/// selecting / selected / committingAction
var hasSelection: Bool {
switch self {
case .idle:
return false
case .selecting, .selected, .committingAction:
return true
}
}
///
var selection: RDEPUBSelection? {
switch self {
case .idle, .selecting:
return nil
case .selected(let selection), .committingAction(let selection, _):
return selection
}
}
}