feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -1,19 +1,8 @@
// RDEPUBReaderController+ContentDelegates.swift
// EPUB
// Web RDEPUBWebContentViewDelegate Native Text
// RDEPUBTextContentViewDelegate
import UIKit
/// RDEPUBReaderController
///
/// EPUB Web Native Text
///
// MARK: - Web EPUB /Web
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
@@ -35,7 +24,6 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
@@ -44,13 +32,15 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
if presentNotePopupIfPossible(for: location, fromSpineIndex: fromSpineIndex) {
return
}
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
@@ -62,13 +52,11 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
/// Web
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
openExternalURLIfAllowed(url)
}
/// Web JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
#if DEBUG
print("EPUB JS Error: \(message)")
@@ -76,10 +64,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
// MARK: - Native Text
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
@@ -88,7 +74,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(selection)
}
///
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) {
readerView.handleContentTap(at: point, in: contentView)
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -115,8 +104,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
/// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
@@ -169,38 +156,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
///
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let fragmentID = nearestFragmentID(
beforeOrAt: startOffset,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
)
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: resolvedPage.page.href,
progression: Double(startOffset) / Double(chapterLength),
lastProgression: Double(endOffset) / Double(chapterLength),
fragment: fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(
start: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: startOffset,
fragmentID: fragmentID
),
end: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: endOffset,
fragmentID: fragmentID
)
)
let chapterData = makeRuntimeChapterData(from: resolvedPage)
let location = chapterData.location(
for: NSRange(location: startOffset, length: max(endOffset - startOffset + 1, 1)),
bookIdentifier: currentBookIdentifier
)
if let publication {
return publication.resourceResolver.normalizedLocation(
@@ -231,7 +194,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return location
}
/// spine
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
@@ -259,7 +221,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
)
}
/// +
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
@@ -280,6 +241,15 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let spineIndex = readerContext.normalizedSpineIndex(for: location),
let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: spineIndex),
let offset = runtimeChapter.chapterOffsetMap.chapterOffset(forCFI: location.cfi) {
return offset
}
if let cfi = RDEPUBCFICompatibility.parseLossy(location.cfi),
let cfiOffset = RDEPUBCFIResolver.resolve(cfi).chapterOffset {
return cfiOffset
}
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
@@ -308,8 +278,6 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
return bestID
}
// MARK: -
private func shouldAllowExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
@@ -340,6 +308,52 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
present(alert, animated: true)
}
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
guard let publication else { return false }
let sourceHref = publication.resourceResolver.href(forSpineIndex: fromSpineIndex) ?? location.href
let sourceLocation = currentVisibleLocation()
let sourceCFI = sourceLocation?.cfi
let resolver = RDEPUBNoteResolver(resourceResolver: publication.resourceResolver)
guard let note = resolver.resolveInternalLink(
sourceHref: sourceHref,
sourceCFI: sourceCFI,
targetLocation: location,
sourceLocation: sourceLocation,
relativeToSpineIndex: fromSpineIndex
) else {
return false
}
RDEPUBNotePopupCoordinator.present(
note,
from: self,
onReturnToSource: { [weak self] in
guard let self, let sourceLocation = note.sourceLocation else { return }
self.navigateToLocation(sourceLocation, relativeToSpineIndex: nil, animated: true)
},
onOpenNoteLocation: { [weak self] in
guard let self else { return }
self.navigateToLocation(note.targetLocation, relativeToSpineIndex: nil, animated: true)
}
)
return true
}
private func navigateToLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int?,
animated: Bool
) {
guard let pageNumber = readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: spineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: animated)
}
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded()
@@ -415,7 +429,28 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
}
private extension RDEPUBReaderController {
func makeRuntimeChapterData(from resolvedPage: RDEPUBResolvedPage) -> RDEPUBChapterData {
let textChapter = RDEPUBTextChapter(
chapterIndex: resolvedPage.chapterIndex,
spineIndex: resolvedPage.chapter.spineIndex,
href: resolvedPage.chapter.href,
title: resolvedPage.chapter.title,
attributedContent: resolvedPage.chapter.typesetAttributedString,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
cfiMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
pageBreakReasons: resolvedPage.chapter.pages.map(\.metadata.breakReason),
pages: resolvedPage.chapter.pages
)
return RDEPUBChapterData(
chapter: textChapter,
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
)
}
}
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
var onBackgroundTap: (() -> Void)?
var tooltipView: RDEPUBAttachmentTooltipView? {
@@ -449,17 +484,26 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
}
private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -468,6 +512,7 @@ private final class RDEPUBAttachmentTooltipView: UIView {
label.lineBreakMode = .byWordWrapping
return label
}()
private let shapeLayer = CAShapeLayer()
override init(frame: CGRect) {