feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBNotePopupCoordinator {
|
||||
|
||||
static func present(
|
||||
_ note: RDEPUBResolvedNote,
|
||||
from controller: UIViewController,
|
||||
onReturnToSource: (() -> Void)? = nil,
|
||||
onOpenNoteLocation: (() -> Void)? = nil
|
||||
) {
|
||||
let popup = RDEPUBNotePopupViewController(
|
||||
note: note,
|
||||
onReturnToSource: onReturnToSource,
|
||||
onOpenNoteLocation: onOpenNoteLocation
|
||||
)
|
||||
let navigationController = UINavigationController(rootViewController: popup)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
if let sheet = navigationController.sheetPresentationController {
|
||||
sheet.detents = [.medium(), .large()]
|
||||
sheet.prefersGrabberVisible = true
|
||||
}
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBNotePopupViewController: UIViewController {
|
||||
|
||||
private let note: RDEPUBResolvedNote
|
||||
private let onReturnToSource: (() -> Void)?
|
||||
private let onOpenNoteLocation: (() -> Void)?
|
||||
|
||||
private let textView = UITextView()
|
||||
private let actionsStackView = UIStackView()
|
||||
|
||||
init(
|
||||
note: RDEPUBResolvedNote,
|
||||
onReturnToSource: (() -> Void)? = nil,
|
||||
onOpenNoteLocation: (() -> Void)? = nil
|
||||
) {
|
||||
self.note = note
|
||||
self.onReturnToSource = onReturnToSource
|
||||
self.onOpenNoteLocation = onOpenNoteLocation
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = note.title ?? "注释"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .done,
|
||||
target: self,
|
||||
action: #selector(close)
|
||||
)
|
||||
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textView.isEditable = false
|
||||
textView.alwaysBounceVertical = true
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = UIEdgeInsets(top: 18, left: 18, bottom: 24, right: 18)
|
||||
textView.attributedText = attributedContent()
|
||||
textView.accessibilityIdentifier = "epub.reader.note.text"
|
||||
actionsStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
actionsStackView.axis = .horizontal
|
||||
actionsStackView.spacing = 12
|
||||
actionsStackView.distribution = .fillEqually
|
||||
actionsStackView.accessibilityIdentifier = "epub.reader.note.actions"
|
||||
configureActionButtons()
|
||||
|
||||
view.addSubview(actionsStackView)
|
||||
view.addSubview(textView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
actionsStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18),
|
||||
actionsStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18),
|
||||
actionsStackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||
actionsStackView.heightAnchor.constraint(equalToConstant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 36),
|
||||
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
textView.topAnchor.constraint(equalTo: actionsStackView.bottomAnchor, constant: actionsStackView.arrangedSubviews.isEmpty ? 0 : 8),
|
||||
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func configureActionButtons() {
|
||||
if note.sourceLocation != nil, onReturnToSource != nil {
|
||||
actionsStackView.addArrangedSubview(
|
||||
makeActionButton(title: "返回原文", action: #selector(returnToSource))
|
||||
)
|
||||
}
|
||||
if onOpenNoteLocation != nil {
|
||||
actionsStackView.addArrangedSubview(
|
||||
makeActionButton(title: "打开注释原文", action: #selector(openNoteLocation))
|
||||
)
|
||||
}
|
||||
actionsStackView.isHidden = actionsStackView.arrangedSubviews.isEmpty
|
||||
}
|
||||
|
||||
private func makeActionButton(title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.cornerStyle = .medium
|
||||
configuration.title = title
|
||||
configuration.baseBackgroundColor = .secondarySystemBackground
|
||||
configuration.baseForegroundColor = .label
|
||||
button.configuration = configuration
|
||||
if title == "返回原文" {
|
||||
button.accessibilityIdentifier = "epub.reader.note.return"
|
||||
} else if title == "打开注释原文" {
|
||||
button.accessibilityIdentifier = "epub.reader.note.open"
|
||||
}
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func attributedContent() -> NSAttributedString {
|
||||
let html = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font: -apple-system-body;
|
||||
color: #1f1f1f;
|
||||
line-height: 1.55;
|
||||
}
|
||||
a { color: #3b6ea8; }
|
||||
img, svg, table { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>\(note.html)</body>
|
||||
</html>
|
||||
"""
|
||||
guard let data = html.data(using: .utf8),
|
||||
let attributed = try? NSMutableAttributedString(
|
||||
data: data,
|
||||
options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
],
|
||||
documentAttributes: nil
|
||||
) else {
|
||||
return NSAttributedString(string: note.plainText)
|
||||
}
|
||||
return attributed
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func returnToSource() {
|
||||
dismiss(animated: true) { [onReturnToSource] in
|
||||
onReturnToSource?()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func openNoteLocation() {
|
||||
dismiss(animated: true) { [onOpenNoteLocation] in
|
||||
onOpenNoteLocation?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 底部工具栏
|
||||
|
||||
/// 阅读器底部工具栏
|
||||
/// 提供目录、书签、高亮管理、新建标注和设置五个功能入口
|
||||
/// 按钮可见性可根据 configuration 动态调整
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 点击目录按钮回调
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
/// 点击书签列表按钮回调
|
||||
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
/// 点击高亮列表按钮回调
|
||||
|
||||
var onShowHighlights: (() -> Void)?
|
||||
/// 点击新建标注按钮回调
|
||||
|
||||
var onAddHighlight: (() -> Void)?
|
||||
/// 点击设置按钮回调
|
||||
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
/// 水平等分布局的按钮容器
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
@@ -31,21 +21,20 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
return view
|
||||
}()
|
||||
|
||||
/// 目录按钮
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 书签列表按钮
|
||||
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 高亮列表按钮
|
||||
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 新建标注按钮
|
||||
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
/// 设置按钮
|
||||
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.bottomToolbar"
|
||||
|
||||
|
||||
addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(chapterButton)
|
||||
@@ -99,9 +88,6 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 按钮状态控制
|
||||
|
||||
/// 设置新建标注按钮是否可用(有选中文本时可用)
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 目录面板
|
||||
|
||||
/// EPUB 阅读器的目录面板控制器
|
||||
/// 以列表形式展示书籍目录,支持多级缩进,当前所在章节高亮显示
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
/// 用户选择目录项时的回调
|
||||
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
|
||||
private let currentItem: RDEPUBReaderTableOfContentsItem?
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
init(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
// RDEPUBReaderController+DataSource.swift
|
||||
// EPUB 阅读器数据源与代理实现
|
||||
// 实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 数据源与代理扩展
|
||||
///
|
||||
/// 本文件实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||||
/// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||||
|
||||
// MARK: - RDReaderView 数据源与代理
|
||||
|
||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
/// 返回阅读器总页数
|
||||
|
||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: resolvedPage.page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page)
|
||||
)
|
||||
@@ -40,11 +39,22 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: textBook.pages.count,
|
||||
configuration: configuration,
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page)
|
||||
)
|
||||
@@ -68,14 +78,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
return contentView
|
||||
}
|
||||
|
||||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||||
(textBook == nil && readerContext.bookPageMap == nil)
|
||||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
/// 获取指定文本页面上的高亮标注列表
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
@@ -91,9 +99,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前文本页需要展示的搜索状态。
|
||||
/// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引,
|
||||
/// 这样渲染层不需要再关心 href 规范化差异。
|
||||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||
guard let globalSearchState = searchState else { return nil }
|
||||
|
||||
@@ -237,6 +242,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
@@ -274,31 +280,25 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
return publication.resourceResolver.normalizedHref(href) ?? href
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
readerContext.textChapterData(forNormalizedHref: href)
|
||||
}
|
||||
|
||||
/// 返回阅读器顶部工具视图
|
||||
public func topToolView(readerView: RDReaderView) -> UIView? {
|
||||
topToolView
|
||||
}
|
||||
|
||||
/// 返回阅读器底部工具视图
|
||||
public func bottomToolView(readerView: RDReaderView) -> UIView? {
|
||||
bottomToolView
|
||||
}
|
||||
|
||||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
// 如果正在重新分页,跳过后续操作,避免状态冲突
|
||||
guard !isRepaginating else { return }
|
||||
|
||||
// 用户开始导航时,应用后台解析完成的完整 map
|
||||
let previousCurrentPage = readerView.currentPage
|
||||
runtime.applyPendingFullPageMapIfNeeded()
|
||||
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
|
||||
@@ -320,7 +320,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
|
||||
persist(location: location)
|
||||
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
|
||||
// 记录翻页到 JumpSession
|
||||
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
return
|
||||
}
|
||||
@@ -332,17 +332,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
readingSession?.transition(to: .idle)
|
||||
}
|
||||
|
||||
// 记录翻页到 JumpSession
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
}
|
||||
|
||||
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
||||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||||
_ = isLandscape
|
||||
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
|
||||
}
|
||||
|
||||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||||
guard textBook != nil || readerContext.bookPageMap != nil,
|
||||
!isRepaginating,
|
||||
@@ -365,12 +362,12 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
|
||||
isReconcilingTextPaginationSize = true
|
||||
// 使用防抖延迟,避免连续快速点击字号时重复触发
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.isReconcilingTextPaginationSize = false
|
||||
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
|
||||
// 再次检查尺寸是否仍然变化,避免过期的重新分页
|
||||
|
||||
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
|
||||
guard currentSize.width > 0, currentSize.height > 0 else { return }
|
||||
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
// RDEPUBReaderController+PublicAPI.swift
|
||||
// RDEPUBReaderController 的公开 API 扩展,提供书签、高亮、目录跳转、搜索等操作
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: - Public Reader Commands
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 重新加载书籍,重置所有状态并重新解析 EPUB
|
||||
|
||||
public func reloadBook() {
|
||||
runtime.reloadBook()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameter location: 目标阅读位置
|
||||
public func go(to location: RDEPUBLocation) {
|
||||
guard publication != nil else { return }
|
||||
_ = runtime.go(to: location)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
runtime.go(toPageNumber: pageNumber, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除当前文本选中状态
|
||||
public func clearSelection() {
|
||||
runtime.clearSelection()
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 匹配的书签对象,不存在时返回 nil
|
||||
public func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
runtime.bookmark(withID: id)
|
||||
}
|
||||
|
||||
/// 根据唯一标识获取高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 匹配的高亮对象,不存在时返回 nil
|
||||
public func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
runtime.highlight(withID: id)
|
||||
}
|
||||
|
||||
/// 获取当前页的原生文本语义摘要,用于调试和可访问性
|
||||
/// - Returns: 包含页码、断行原因、块类型等信息的摘要字符串,无内容时返回 nil
|
||||
public func nativeTextSemanticSummary() -> String? {
|
||||
let resolvedPage: RDEPUBTextPage?
|
||||
if let textBook {
|
||||
@@ -79,12 +59,6 @@ extension RDEPUBReaderController {
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// 添加高亮标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - color: 高亮颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -94,13 +68,6 @@ extension RDEPUBReaderController {
|
||||
runtime.addHighlight(from: selection, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 添加指定样式的标注,从当前选中文本或指定选区创建
|
||||
/// - Parameters:
|
||||
/// - selection: 文本选区,默认使用 currentSelection
|
||||
/// - style: 标注样式(下划线、高亮等)
|
||||
/// - color: 标注颜色(CSS 格式),默认黄色
|
||||
/// - note: 可选批注文字
|
||||
/// - Returns: 创建的高亮对象,重复时返回 nil
|
||||
@discardableResult
|
||||
public func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -111,72 +78,40 @@ extension RDEPUBReaderController {
|
||||
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 插入或更新高亮标注(存在则更新,不存在则插入)
|
||||
/// - Parameter highlight: 要写入的高亮对象
|
||||
/// - Returns: 写入后的高亮对象
|
||||
@discardableResult
|
||||
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
runtime.upsertHighlight(highlight)
|
||||
}
|
||||
|
||||
/// 移除指定高亮标注
|
||||
/// - Parameter id: 高亮 ID
|
||||
/// - Returns: 被移除的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
runtime.removeHighlight(id: id)
|
||||
}
|
||||
|
||||
/// 更新高亮标注的批注内容
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - note: 新的批注文字,传 nil 清除批注
|
||||
/// - Returns: 更新后的高亮对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
runtime.updateHighlightNote(id: id, note: note)
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮标注所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 高亮 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toHighlightID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 移除当前书籍的所有高亮标注
|
||||
public func removeAllHighlights() {
|
||||
runtime.removeAllHighlights()
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: EPUB 原生目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到目录项对应的阅读位置
|
||||
/// - Parameters:
|
||||
/// - item: RDReader 目录项
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
|
||||
go(toTableOfContentsHref: item.href, animated: animated)
|
||||
}
|
||||
|
||||
/// 通过目录 href 跳转到对应阅读位置,支持带锚点的 href
|
||||
/// - Parameters:
|
||||
/// - href: 目录资源路径(可含 #fragment)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
|
||||
guard publication != nil else { return false }
|
||||
@@ -193,61 +128,40 @@ extension RDEPUBReaderController {
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签
|
||||
/// - Parameter note: 可选批注文字
|
||||
/// - Returns: 创建的书签对象,位置已存在书签时返回 nil
|
||||
@discardableResult
|
||||
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态(有则移除,无则添加)
|
||||
/// - Parameter note: 添加时可附带的批注文字
|
||||
/// - Returns: 添加时返回新书签,移除时返回被移除的书签
|
||||
@discardableResult
|
||||
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
runtime.toggleBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 移除指定书签
|
||||
/// - Parameter id: 书签 ID
|
||||
/// - Returns: 被移除的书签对象,不存在时返回 nil
|
||||
@discardableResult
|
||||
public func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
runtime.removeBookmark(id: id)
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置
|
||||
/// - Parameters:
|
||||
/// - id: 书签 ID
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否跳转成功
|
||||
@discardableResult
|
||||
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
runtime.go(toBookmarkID: id, animated: animated)
|
||||
}
|
||||
|
||||
/// 执行全文搜索,匹配结果非空时自动跳转到第一个匹配项,无匹配时清除搜索状态
|
||||
/// - Parameter keyword: 搜索关键词,空字符串会清除搜索
|
||||
public func search(keyword: String) {
|
||||
runtime.search(keyword: keyword)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否存在下一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchNext() -> Bool {
|
||||
runtime.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否存在上一个匹配项并跳转成功
|
||||
@discardableResult
|
||||
public func searchPrevious() -> Bool {
|
||||
runtime.searchPrevious()
|
||||
}
|
||||
|
||||
/// 清除搜索状态和高亮
|
||||
public func clearSearch() {
|
||||
runtime.clearSearch()
|
||||
}
|
||||
|
||||
@@ -1,47 +1,32 @@
|
||||
// RDEPUBReaderController+RenderSupport.swift
|
||||
// EPUB 阅读器渲染支持辅助方法
|
||||
// 提供获取当前布局上下文、偏好设置、页面尺寸、渲染样式、文本布局配置,
|
||||
// 以及构建渲染请求、管理分页宿主视图和回退位置查询等功能。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 渲染支持扩展
|
||||
///
|
||||
/// 本文件提供阅读器渲染所需的辅助方法,包括获取当前布局上下文、偏好设置、页面尺寸、
|
||||
/// 渲染样式、文本布局配置,以及构建渲染请求和回退位置查询等功能。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 获取当前导航器布局上下文(视口尺寸、方向等)
|
||||
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
readerContext.currentLayoutContext()
|
||||
}
|
||||
|
||||
/// 获取当前阅读偏好设置
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
readerContext.currentPreferences()
|
||||
}
|
||||
|
||||
/// 获取当前文本页面尺寸
|
||||
func currentTextPageSize() -> CGSize {
|
||||
readerContext.currentTextPageSize()
|
||||
}
|
||||
|
||||
/// 获取当前文本渲染样式(字体、行高等)
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
readerContext.currentTextRenderStyle()
|
||||
}
|
||||
|
||||
/// 获取指定页面尺寸下的文本布局配置
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
readerContext.currentTextLayoutConfig(pageSize: pageSize)
|
||||
}
|
||||
|
||||
/// 获取已解析的文本渲染器实例
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
readerContext.resolvedTextRenderer()
|
||||
}
|
||||
|
||||
/// 确保分页宿主视图已添加到视图层级(位于屏幕外用于预计算分页)
|
||||
func ensurePaginationHostView() -> UIView {
|
||||
let viewportSize = currentLayoutContext().viewportSize
|
||||
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
|
||||
@@ -53,7 +38,6 @@ extension RDEPUBReaderController {
|
||||
return paginationHostView
|
||||
}
|
||||
|
||||
/// 为指定页面索引构建渲染请求(含位置、高亮、搜索状态)
|
||||
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
|
||||
guard let publication, activePages.indices.contains(pageIndex) else {
|
||||
return nil
|
||||
@@ -75,13 +59,11 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定页面索引的回退位置(当无法确定精确位置时使用)
|
||||
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
|
||||
guard activePages.indices.contains(pageIndex) else { return nil }
|
||||
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 获取指定固定布局页面上的高亮标注列表
|
||||
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
|
||||
guard let publication else { return [] }
|
||||
if let spread = page.fixedSpread {
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
// RDEPUBReaderController+RuntimeBridge.swift
|
||||
// EPUB 阅读器运行时桥接层
|
||||
// 将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
import UIKit
|
||||
|
||||
/// RDEPUBReaderController 运行时桥接扩展
|
||||
///
|
||||
/// 本文件将阅读器控制器的公开行为委托给 runtime 对象执行,涵盖配置应用、
|
||||
/// 出版物加载与分页、阅读位置恢复、加载状态管理、错误处理等生命周期操作。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 应用阅读器视图配置,处理显示类型/翻页方向/横屏双页等变更并恢复位置
|
||||
|
||||
func applyReaderViewConfiguration() {
|
||||
let resolvedDirection = resolvedPageDirection()
|
||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
@@ -32,17 +23,14 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动初始加载流程(如果尚未加载)
|
||||
func startInitialLoadIfNeeded() {
|
||||
runtime.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 加载 EPUB 出版物
|
||||
func loadPublication() {
|
||||
runtime.loadPublication()
|
||||
}
|
||||
|
||||
/// 应用已解析的出版物数据(解析器、出版物模型、书签、高亮等)
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -61,17 +49,14 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对出版物执行分页计算
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用文本书籍数据并恢复阅读位置
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照(页面列表与章节信息)
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@@ -79,17 +64,14 @@ extension RDEPUBReaderController {
|
||||
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 保留当前位置重新执行分页
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
runtime.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -103,27 +85,22 @@ extension RDEPUBReaderController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的位置
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
runtime.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 获取持久化存储的阅读位置
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
readerContext.persistenceLocation()
|
||||
}
|
||||
|
||||
/// 持久化当前阅读位置
|
||||
func persist(location: RDEPUBLocation) {
|
||||
readerContext.persist(location: location)
|
||||
}
|
||||
|
||||
/// 更新当前文本选区状态
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
runtime.annotationCoordinator.updateCurrentSelection(selection)
|
||||
}
|
||||
|
||||
/// 将选区限定到指定 spine 范围内
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@@ -131,60 +108,49 @@ extension RDEPUBReaderController {
|
||||
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保留当前位置
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
runtime.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部文本书籍
|
||||
func rebuildExternalTextBook() {
|
||||
runtime.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 更新阅读器 UI 装饰层(导航栏、工具栏等)
|
||||
func updateReaderChrome() {
|
||||
runtime.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 展示书签管理界面
|
||||
func presentBookmarksManager() {
|
||||
runtime.presentBookmarksManager()
|
||||
}
|
||||
|
||||
/// 展示高亮标注管理界面
|
||||
func presentHighlightsManager() {
|
||||
runtime.presentHighlightsManager()
|
||||
}
|
||||
|
||||
/// 展示标注创建界面
|
||||
func presentAnnotationCreation() {
|
||||
runtime.presentAnnotationCreation()
|
||||
}
|
||||
|
||||
/// 处理选区菜单操作(复制、标注、分享等)
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
runtime.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
/// 展示阅读设置界面
|
||||
func presentSettings() {
|
||||
runtime.presentSettings()
|
||||
}
|
||||
|
||||
/// 使用闭包更新阅读器配置
|
||||
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
|
||||
var nextConfiguration = configuration
|
||||
update(&nextConfiguration)
|
||||
configuration = nextConfiguration
|
||||
}
|
||||
|
||||
/// 设置屏幕亮度并持久化
|
||||
func setScreenBrightness(_ brightness: CGFloat) {
|
||||
currentBrightness = max(0, min(1, brightness))
|
||||
persistReaderSettingsIfNeeded()
|
||||
}
|
||||
|
||||
/// 持久化阅读器设置(亮度、配置等)
|
||||
func persistReaderSettingsIfNeeded() {
|
||||
let settings = RDEPUBReaderSettings.capture(
|
||||
configuration: configuration,
|
||||
@@ -193,7 +159,6 @@ extension RDEPUBReaderController {
|
||||
persistence?.saveReaderSettings(settings)
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要重新分页
|
||||
func requiresRepagination(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@@ -210,7 +175,6 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
|
||||
}
|
||||
|
||||
/// 判断配置变更是否需要刷新可见内容
|
||||
func requiresVisibleRefresh(
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
@@ -220,17 +184,14 @@ extension RDEPUBReaderController {
|
||||
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
|
||||
}
|
||||
|
||||
/// 展示目录界面
|
||||
func presentTableOfContents() {
|
||||
runtime.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
runtime.handleBackAction()
|
||||
}
|
||||
|
||||
/// 处理错误:停止分页、隐藏加载指示器、显示错误信息并通知代理
|
||||
func handle(error: Error) {
|
||||
isRepaginating = false
|
||||
hideLoading()
|
||||
@@ -243,23 +204,19 @@ extension RDEPUBReaderController {
|
||||
delegate?.epubReader(self, didFailWithError: error)
|
||||
}
|
||||
|
||||
/// 显示加载指示器
|
||||
func showLoading() {
|
||||
errorLabel.isHidden = true
|
||||
loadingIndicator.startAnimating()
|
||||
}
|
||||
|
||||
/// 隐藏加载指示器
|
||||
func hideLoading() {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
/// 获取当前视口签名(用于检测视口变化)
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
runtime.currentViewportSignature()
|
||||
}
|
||||
|
||||
/// 检测并处理视口变化(旋转、尺寸变化等)
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
@@ -267,21 +224,17 @@ extension RDEPUBReaderController {
|
||||
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
/// 获取指定页面的搜索结果展示状态
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
runtime.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
/// 根据出版物阅读方向解析页面翻页方向
|
||||
private func resolvedPageDirection() -> RDReaderView.PageDirection {
|
||||
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 手势识别器代理
|
||||
|
||||
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
/// 手势识别器代理,始终返回 true 允许手势触发
|
||||
|
||||
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
// RDEPUBReaderController+TableOfContents.swift
|
||||
// EPUB 阅读器目录功能
|
||||
// 提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// RDEPUBReaderController 目录扩展
|
||||
///
|
||||
/// 本文件提供目录(Table of Contents)相关功能,包括解析当前页面对应的目录项
|
||||
/// 以及将嵌套目录树结构扁平化为线性列表。
|
||||
|
||||
extension RDEPUBReaderController {
|
||||
/// 解析当前阅读位置对应的目录项,按页码或 href 匹配
|
||||
|
||||
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
|
||||
let items = flattenedTableOfContentsItems(
|
||||
from: publication?.tableOfContents ?? [],
|
||||
@@ -51,7 +42,6 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将嵌套目录树递归扁平化为线性列表,并计算每项目标页码
|
||||
func flattenedTableOfContentsItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0,
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文件说明
|
||||
|
||||
/// EPUB 阅读器主控制器
|
||||
/// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验
|
||||
/// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面
|
||||
///
|
||||
/// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation
|
||||
///
|
||||
/// **核心职责:**
|
||||
/// - EPUB 解析与分页(支持流式排版和固定布局)
|
||||
/// - 阅读位置的持久化与恢复
|
||||
/// - 高亮、书签的增删改查
|
||||
/// - 工具栏、目录、设置等 UI 面板的协调管理
|
||||
/// - 搜索功能(全文搜索、前后跳转)
|
||||
///
|
||||
/// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation
|
||||
|
||||
// MARK: - 阅读器主控制器
|
||||
|
||||
public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
// MARK: - 公开属性
|
||||
|
||||
/// 阅读器委托,用于接收阅读状态变更通知
|
||||
public weak var delegate: RDEPUBReaderDelegate?
|
||||
|
||||
/// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
readerContext.configuration = configuration
|
||||
@@ -54,65 +31,58 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前阅读位置
|
||||
public var currentLocation: RDEPUBLocation? {
|
||||
currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 当前页码(从 1 开始),nil 表示尚未加载
|
||||
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
|
||||
}
|
||||
|
||||
/// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。
|
||||
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()
|
||||
}
|
||||
|
||||
// MARK: - 私有属性
|
||||
|
||||
/// EPUB 文件 URL
|
||||
let epubURL: URL
|
||||
|
||||
let persistence: RDEPUBReaderPersistence?
|
||||
|
||||
let dependencies: RDEPUBReaderDependencies
|
||||
|
||||
let readerView = RDReaderView()
|
||||
|
||||
let loadingIndicator: UIActivityIndicatorView = {
|
||||
UIActivityIndicatorView(style: .large)
|
||||
}()
|
||||
|
||||
let errorLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
@@ -121,97 +91,115 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
label.isHidden = true
|
||||
return label
|
||||
}()
|
||||
|
||||
let paginationHostView = UIView()
|
||||
|
||||
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()
|
||||
/// 搜索栏是否当前可见
|
||||
|
||||
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 paginator: RDEPUBPaginator? {
|
||||
get { readerContext.paginator }
|
||||
set { readerContext.paginator = 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)
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
/// 使用 EPUB 文件 URL 初始化阅读器
|
||||
/// 会自动从持久化存储中恢复用户的阅读偏好设置
|
||||
/// - Parameters:
|
||||
/// - epubURL: EPUB 文件的本地 URL
|
||||
/// - configuration: 阅读器配置,默认使用 .default
|
||||
/// - persistence: 持久化策略,默认使用 UserDefaults
|
||||
public init(
|
||||
epubURL: URL,
|
||||
configuration: RDEPUBReaderConfiguration = .default,
|
||||
@@ -225,7 +213,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
self.dependencies = dependencies
|
||||
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
// Sync context after super.init
|
||||
|
||||
readerContext.dependencies = dependencies
|
||||
readerContext.configuration = self.configuration
|
||||
readerContext.epubURL = epubURL
|
||||
@@ -245,15 +233,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||
/// - Parameters:
|
||||
/// - textBook: 已分页的 TextBook 实例
|
||||
/// - bookIdentifier: 书籍唯一标识符,用于持久化
|
||||
/// - title: 书籍标题
|
||||
/// - textFileURL: 原始文本文件 URL,用于重建 TextBook
|
||||
/// - configuration: 阅读器配置
|
||||
/// - persistence: 持久化策略
|
||||
public convenience init(
|
||||
textBook: RDEPUBTextBook,
|
||||
bookIdentifier: String,
|
||||
@@ -317,10 +296,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||
}
|
||||
|
||||
// MARK: - 搜索栏管理
|
||||
|
||||
/// 显示搜索栏。工具栏可见时立即添加到视图层级并滑入动画;否则仅标记状态,
|
||||
/// 等待 `handleToolViewVisibilityChanged` 在工具栏显示时再安装。
|
||||
func showSearchBar() {
|
||||
guard !isSearchBarVisible else { return }
|
||||
isSearchBarVisible = true
|
||||
@@ -367,7 +342,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将搜索栏添加到 readerView 并执行滑入动画
|
||||
private func installSearchBarView() {
|
||||
guard searchBarView.superview == nil else { return }
|
||||
searchBarView.apply(theme: configuration.theme)
|
||||
@@ -403,8 +377,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏搜索栏,带动画滑出
|
||||
/// - Parameter clearSearch: 是否同时清除搜索状态
|
||||
func hideSearchBar(clearSearch: Bool = false) {
|
||||
guard isSearchBarVisible else { return }
|
||||
isSearchBarVisible = false
|
||||
@@ -425,7 +397,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏匹配计数
|
||||
func updateSearchCount() {
|
||||
guard let searchState else {
|
||||
searchBarView.showNoResults()
|
||||
@@ -438,7 +409,6 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||
func handleToolViewVisibilityChanged(isVisible: Bool) {
|
||||
if isVisible {
|
||||
if isSearchBarVisible {
|
||||
@@ -456,6 +426,7 @@ 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)
|
||||
|
||||
@@ -1,93 +1,34 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器委托协议
|
||||
|
||||
/// EPUB 阅读器的委托协议
|
||||
/// 用于向调用方通知阅读器的各种状态变更事件
|
||||
/// 所有方法均提供默认空实现,调用方可选择性实现感兴趣的方法
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
/// 阅读器成功打开书籍时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - publication: 解析后的出版物对象
|
||||
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
|
||||
/// 阅读位置更新时调用(翻页、跳转等)
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - location: 新的阅读位置
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
|
||||
/// 阅读器翻到书籍末尾时调用
|
||||
/// - Parameter reader: 阅读器控制器
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
|
||||
/// 用户选中文本发生变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - selection: 当前选中文本,nil 表示取消选中
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
|
||||
/// 高亮标注列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - highlights: 当前所有高亮
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
|
||||
/// 书签列表更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - bookmarks: 当前所有书签
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
|
||||
/// 搜索结果更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - result: 搜索结果摘要,nil 表示无搜索
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
|
||||
/// 当前搜索匹配项变化时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - match: 当前匹配项,nil 表示无匹配
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
|
||||
/// 当前所在目录项更新时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - item: 当前目录项
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
|
||||
/// 用户点击外部链接时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 外部链接打开前的拦截钩子,返回 false 可阻止打开
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
/// - Returns: 是否允许打开该链接
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - error: 错误对象
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
|
||||
/// 允许调用方自定义顶部工具栏
|
||||
/// 在 viewDidLoad 时调用,可在工具栏上添加自定义按钮
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - topToolView: 顶部工具栏视图
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
// MARK: - 默认空实现
|
||||
|
||||
/// 所有委托方法的默认空实现,调用方可只实现需要的方法
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 高亮管理面板
|
||||
|
||||
/// EPUB 阅读器的高亮标注管理控制器
|
||||
/// 以列表形式展示所有高亮和批注,支持筛选(全部/批注/高亮)、跳转到位置、编辑批注、删除
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个高亮位置时的回调
|
||||
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户编辑高亮批注后的回调
|
||||
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
/// 用户删除高亮时的回调
|
||||
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
/// 高亮列表(按创建时间降序排列)
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
/// 当前主题配置
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
/// 提供章节标题的回调,用于显示高亮所在的章节名
|
||||
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
/// 筛选控件:全部、批注、高亮
|
||||
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
@@ -50,6 +45,8 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
configureFilterControl()
|
||||
@@ -87,6 +84,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
private func configureFilterControl() {
|
||||
filterControl.selectedSegmentIndex = 0
|
||||
filterControl.accessibilityIdentifier = "epub.reader.highlights.filter"
|
||||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||||
navigationItem.titleView = filterControl
|
||||
}
|
||||
@@ -96,6 +94,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
@@ -109,6 +108,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.highlights.empty"
|
||||
tableView.backgroundView = label
|
||||
}
|
||||
|
||||
@@ -216,18 +216,16 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 书签管理面板
|
||||
|
||||
/// EPUB 阅读器的书签管理控制器
|
||||
/// 以列表形式展示所有书签,支持跳转到书签位置和删除书签
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
/// 用户选择跳转到某个书签位置时的回调
|
||||
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
/// 用户删除书签时的回调
|
||||
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
@@ -298,6 +296,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
|
||||
tableView.backgroundView = label
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,45 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 持久化协议
|
||||
|
||||
/// 阅读器持久化协议
|
||||
/// 定义了阅读位置、书签、高亮和用户设置的存储接口
|
||||
/// 调用方可实现此协议以对接自定义存储(如数据库、云端同步等)
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
/// 加载指定书籍的上次阅读位置
|
||||
/// - Parameter bookIdentifier: 书籍唯一标识符
|
||||
/// - Returns: 保存的阅读位置,nil 表示首次打开
|
||||
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
|
||||
/// 保存指定书籍的当前阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 阅读位置
|
||||
/// - bookIdentifier: 书籍唯一标识符
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有书签
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
|
||||
/// 保存指定书籍的所有书签
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
|
||||
/// 加载指定书籍的所有高亮标注
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
|
||||
/// 保存指定书籍的所有高亮标注
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
|
||||
/// 加载全局阅读器用户设置(字号、行距、主题等)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
|
||||
/// 保存全局阅读器用户设置
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
#if DEBUG
|
||||
@@ -68,30 +47,18 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults 持久化实现
|
||||
|
||||
/// 基于 UserDefaults 的持久化实现
|
||||
/// 数据以 JSON 格式存储,按书籍标识符分隔存储位置
|
||||
/// 适合轻量级场景,大量数据建议改用 SQLite 或文件系统
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
/// UserDefaults 实例
|
||||
|
||||
private let defaults: UserDefaults
|
||||
/// 阅读位置的 key 前缀
|
||||
|
||||
private let locationPrefix: String
|
||||
/// 书签列表的 key 前缀
|
||||
|
||||
private let bookmarksPrefix: String
|
||||
/// 高亮列表的 key 前缀
|
||||
|
||||
private let highlightsPrefix: String
|
||||
/// 用户设置的 key
|
||||
|
||||
private let settingsKey: String
|
||||
|
||||
/// 初始化 UserDefaults 持久化策略
|
||||
/// - Parameters:
|
||||
/// - defaults: UserDefaults 实例,默认 .standard
|
||||
/// - locationPrefix: 阅读位置 key 前缀
|
||||
/// - bookmarksPrefix: 书签 key 前缀
|
||||
/// - highlightsPrefix: 高亮 key 前缀
|
||||
/// - settingsKey: 用户设置 key
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBReaderSearchSection: Equatable {
|
||||
|
||||
struct Item: Equatable {
|
||||
|
||||
let matchIndex: Int
|
||||
|
||||
let previewText: String
|
||||
|
||||
let isCurrent: Bool
|
||||
}
|
||||
|
||||
let title: String
|
||||
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// MARK: - 搜索面板
|
||||
|
||||
/// 阅读器搜索面板
|
||||
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
|
||||
var onSearchTextChanged: ((String) -> Void)?
|
||||
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
|
||||
var onSearchNext: (() -> Void)?
|
||||
|
||||
var onSelectMatch: ((Int) -> Void)?
|
||||
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
let textField: UITextField = {
|
||||
@@ -39,23 +46,35 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}()
|
||||
|
||||
private let backgroundButton = UIButton(type: .custom)
|
||||
|
||||
private let panelView = UIView()
|
||||
|
||||
private let grabberView = UIView()
|
||||
|
||||
private let searchRowView = UIView()
|
||||
|
||||
private let searchFieldContainer = UIView()
|
||||
|
||||
private let searchIcon = UIImageView()
|
||||
|
||||
private let searchFieldDivider = UIView()
|
||||
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
// Preserve legacy accessibility hooks used by existing tests and demo logic.
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let countLabel = UILabel()
|
||||
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
|
||||
private var keyword = ""
|
||||
|
||||
private var currentMatchIndex: Int?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -221,7 +240,6 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
searchFieldContainer.addSubview(searchIcon)
|
||||
searchFieldContainer.addSubview(textField)
|
||||
|
||||
// Legacy shims
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
@@ -405,6 +423,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_searchIsDarkBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
@@ -419,6 +438,7 @@ private extension UIColor {
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
searchSections.count
|
||||
}
|
||||
@@ -485,6 +505,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
|
||||
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onSearchTextChanged?("")
|
||||
@@ -494,15 +515,21 @@ extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
}
|
||||
|
||||
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
|
||||
|
||||
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||
|
||||
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
|
||||
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
|
||||
|
||||
static var highlightTextColor = UIColor.systemBlue
|
||||
|
||||
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
private let cardView = UIView()
|
||||
|
||||
private let previewLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 目录条目
|
||||
|
||||
/// 展平后的目录条目结构体
|
||||
/// 用于目录面板展示,将树形目录展平为一维列表,通过 depth 字段保留层级关系
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
/// 章节标题
|
||||
|
||||
public var title: String
|
||||
/// 章节的 href(相对于 EPUB 容器根目录)
|
||||
|
||||
public var href: String
|
||||
/// 目录层级深度(0 为顶级章节),用于列表缩进显示
|
||||
|
||||
public var depth: Int
|
||||
/// 该章节所在页码(从 1 开始),nil 表示无法确定
|
||||
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 工具栏基类
|
||||
|
||||
/// 阅读器工具栏的基类
|
||||
/// 提供分隔线和主题适配的通用逻辑,子类只需关注具体功能按钮
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
/// 分隔线视图
|
||||
|
||||
private let lineView = UIView()
|
||||
/// 分隔线高度
|
||||
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -25,24 +21,15 @@ open class RDEPUBReaderToolView: UIView {
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
/// 应用主题配色到工具栏
|
||||
/// - Parameter theme: 阅读器主题配置
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
/// 计算分隔线的位置和尺寸,子类可重写以调整分隔线位置(如顶部或底部)
|
||||
/// - Parameter bounds: 工具栏的 bounds
|
||||
/// - Returns: 分隔线的 frame
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具栏按钮
|
||||
|
||||
/// 阅读器工具栏专用按钮
|
||||
/// 继承自 UIButton,用于工具栏中的功能按钮
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 顶部工具栏
|
||||
|
||||
/// 阅读器顶部工具栏
|
||||
/// 显示书名、返回按钮和书签按钮
|
||||
/// 位于阅读内容上方,支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
/// 返回按钮点击回调
|
||||
|
||||
var onBack: (() -> Void)?
|
||||
/// 书签按钮点击回调
|
||||
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
/// 搜索按钮点击回调
|
||||
|
||||
var onSearch: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口签名结构体,用于检测视口尺寸或安全区是否发生显著变化
|
||||
/// 当变化超过阈值时触发重新分页
|
||||
struct RDEPUBViewportSignature: Equatable {
|
||||
/// 视口宽度(pt)
|
||||
|
||||
let width: CGFloat
|
||||
/// 视口高度(pt)
|
||||
|
||||
let height: CGFloat
|
||||
/// 顶部安全区域高度
|
||||
|
||||
let safeTop: CGFloat
|
||||
/// 左侧安全区域宽度
|
||||
|
||||
let safeLeft: CGFloat
|
||||
/// 底部安全区域高度
|
||||
|
||||
let safeBottom: CGFloat
|
||||
/// 右侧安全区域宽度
|
||||
|
||||
let safeRight: CGFloat
|
||||
|
||||
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
|
||||
@@ -26,13 +24,11 @@ struct RDEPUBViewportSignature: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 视口变化的原因枚举,用于决定延迟处理的策略
|
||||
enum RDEPUBViewportChangeReason {
|
||||
/// 视图布局变化(如 Safe Area 更新、分屏调整等)
|
||||
|
||||
case viewLayout
|
||||
/// 屏幕方向旋转过渡
|
||||
|
||||
case orientationTransition
|
||||
}
|
||||
|
||||
/// 原生文本渲染路径的分页快照,包含所有页面和章节信息
|
||||
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Web 内容视图代理
|
||||
|
||||
/// Web 内容视图的代理协议
|
||||
/// 将 WebView 中的事件(位置更新、文本选择、链接点击等)转发给控制器
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
/// WebView 中阅读位置更新时调用
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
/// WebView 中文本选择变化时调用
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
/// 用户从选择菜单中触发操作
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
/// 用户点击内部链接(章节跳转)
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
/// 用户点击外部链接
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
/// WebView 中 JavaScript 执行出错
|
||||
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
// MARK: - Web 内容视图
|
||||
|
||||
/// EPUB 固定布局和 Web 渲染路径的内容视图
|
||||
/// 包装 RDEPUBWebView,提供页码标签和加载/释放资源的控制
|
||||
/// 适用于固定布局 EPUB 和不支持 Native Text 的场景
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB WebView 路径的原生装饰覆盖层。
|
||||
/// 用于在 WebView 上方叠加高亮、下划线等视觉装饰。
|
||||
/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。
|
||||
final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
private let verticalAdjustment: CGFloat = -1
|
||||
@@ -18,8 +15,6 @@ final class RDEPUBWebDecorationOverlayView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 应用装饰数据并触发重绘
|
||||
/// - Parameter decorations: 装饰项数组(高亮、下划线等)
|
||||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||
self.decorations = decorations.filter { !$0.rects.isEmpty }
|
||||
setNeedsDisplay()
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
//
|
||||
// RDURLReaderController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// 文件职责:URL 阅读器入口控制器,根据文件类型自动选择阅读器。
|
||||
// 该文件是 ReadViewSDK 的最简使用入口:
|
||||
// - .epub 文件:直接创建 RDEPUBReaderController
|
||||
// - 其他文本文件:通过 RDPlainTextBookBuilder 分页后创建 RDEPUBReaderController
|
||||
// - 分页失败时回退到纯文本 UITextView 展示
|
||||
//
|
||||
// 架构位置:RDReaderView 四层架构中第四层(读者 UI)的入口控制器。
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// URL 阅读器入口控制器
|
||||
/// 接收一个书籍文件 URL,根据文件类型自动创建对应的阅读器控制器:
|
||||
/// - EPUB 文件:直接使用 RDEPUBReaderController
|
||||
/// - 纯文本文件:先通过 RDPlainTextBookBuilder 分页,再使用 RDEPUBReaderController
|
||||
/// - 分页失败:回退到纯文本 UITextView 展示
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```swift
|
||||
/// let controller = RDURLReaderController(bookURL: fileURL)
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private struct PendingDemoPageRequest {
|
||||
let pageNumber: Int
|
||||
@@ -31,11 +8,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
var attemptCount: Int
|
||||
}
|
||||
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
/// 内嵌的阅读器控制器
|
||||
|
||||
private var embeddedController: UIViewController?
|
||||
private let demoStateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
@@ -58,10 +34,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
private var lastActivatedExternalURL: URL?
|
||||
private var lastReaderErrorDescription = "none"
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
/// - bookURL: 书籍文件的本地 URL
|
||||
/// - epubConfiguration: EPUB 阅读器配置,默认使用标准配置
|
||||
public init(
|
||||
bookURL: URL,
|
||||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||||
@@ -76,7 +48,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 视图加载完成后,设置背景色、标题并嵌入阅读器控制器
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@@ -101,18 +72,11 @@ public final class RDURLReaderController: UIViewController {
|
||||
stopDemoStateTimer()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
/// 跳转到指定页码(Demo 用)
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||||
@@ -125,12 +89,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
return moved
|
||||
}
|
||||
|
||||
/// 执行翻页模式切换序列(Demo 用)
|
||||
/// 按照指定顺序和延迟依次切换翻页模式
|
||||
/// - Parameters:
|
||||
/// - displayTypes: 翻页模式数组
|
||||
/// - initialPageNumber: 可选的初始跳转页码
|
||||
/// - stepDelay: 每步之间的延迟时间(秒)
|
||||
public func runDemoDisplaySequence(
|
||||
_ displayTypes: [RDReaderView.DisplayType],
|
||||
initialPageNumber: Int? = nil,
|
||||
@@ -151,9 +109,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行搜索(Demo 用)
|
||||
/// 显示搜索栏并提交关键词。若阅读器尚未完成加载则延迟到 `didUpdateLocation` 后执行。
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
public func performDemoSearch(keyword: String) {
|
||||
guard let readerController else { return }
|
||||
readerController.showSearchBar()
|
||||
@@ -187,8 +142,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
submitSearchAfterDelay(keyword: keyword, retries: 5)
|
||||
}
|
||||
|
||||
/// 嵌入阅读器控制器到当前视图层级
|
||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
@@ -224,7 +177,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
// 分页失败时回退到纯文本展示
|
||||
|
||||
let fallback = UIViewController()
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
@@ -248,7 +201,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||||
private var readerController: RDEPUBReaderController? {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
@@ -322,7 +274,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
let location = readerController.currentLocation
|
||||
@@ -372,6 +323,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
let location = readerController?.currentLocation
|
||||
let href = encodedDemoLocationHref(location?.href)
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let cfi = encodedDemoField(location?.cfi)
|
||||
let lastCFI = encodedDemoField(location?.lastCFI)
|
||||
let rangeCFI = encodedDemoField(location?.rangeCFI)
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
|
||||
@@ -388,6 +342,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
"selection=\(selection)",
|
||||
"href=\(href)",
|
||||
"progression=\(progression)",
|
||||
"cfi=\(cfi)",
|
||||
"lastCFI=\(lastCFI)",
|
||||
"rangeCFI=\(rangeCFI)",
|
||||
"mode=\(mapSnapshot.mode)",
|
||||
"pagination=\(mapSnapshot.phase)",
|
||||
"knownPages=\(mapSnapshot.knownPages)",
|
||||
@@ -464,14 +421,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸。
|
||||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
||||
/// 以保持与页面展示时的内容几何一致。
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
UIScreen.main.bounds.size
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||||
@@ -483,8 +436,6 @@ public final class RDURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 解码文本文件内容
|
||||
/// 尝试 UTF-8 编码,失败则尝试 GBK 和 GB2312 编码
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
@@ -536,9 +487,8 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
private extension RDReaderView.DisplayType {
|
||||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||||
|
||||
var demoArgumentValue: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
|
||||
+11
@@ -1,22 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
|
||||
let threadName = resolvedThreadName()
|
||||
|
||||
let queueLabel = resolvedQueueLabel()
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
|
||||
}
|
||||
|
||||
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
|
||||
|
||||
let startedAt = CFAbsoluteTimeGetCurrent()
|
||||
log(scope, "START \(message)")
|
||||
do {
|
||||
|
||||
let result = try work()
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
|
||||
return result
|
||||
} catch {
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
|
||||
throw error
|
||||
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
|
||||
}
|
||||
|
||||
private static func resolvedThreadName() -> String {
|
||||
|
||||
if let name = Thread.current.name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
return "main"
|
||||
}
|
||||
|
||||
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
|
||||
}
|
||||
|
||||
|
||||
+13
-27
@@ -1,29 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// BookPageMap 中的单个条目,记录一个章节的轻量元数据。
|
||||
/// 不持有 NSAttributedString,每条约 100 字节。
|
||||
struct RDEPUBBookPageMapEntry {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
/// 该章节的页数
|
||||
|
||||
let pageCount: Int
|
||||
/// 该章节在全书中的绝对起始页码(从 0 开始)
|
||||
|
||||
let absolutePageStart: Int
|
||||
/// fragment ID → 字符偏移量映射
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
}
|
||||
|
||||
/// 全书轻量页码映射:仅存储每章的页数和起始位置,
|
||||
/// 内存成本约 100 字节/章,1000 章 ≈ 100KB。
|
||||
///
|
||||
/// 提供 spineIndex ↔ 绝对页码的双向查询,
|
||||
/// 用于进度条、目录跳转、位置恢复等不依赖内容的场景。
|
||||
struct RDEPUBBookPageMap {
|
||||
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
/// 按 spineIndex 索引的查找表
|
||||
private let indexBySpine: [Int: Int] // spineIndex -> entries 数组下标
|
||||
/// 全书总页数
|
||||
|
||||
private let indexBySpine: [Int: Int]
|
||||
|
||||
let totalPages: Int
|
||||
|
||||
init(entries: [RDEPUBBookPageMapEntry]) {
|
||||
@@ -38,9 +35,6 @@ struct RDEPUBBookPageMap {
|
||||
|
||||
static let empty = RDEPUBBookPageMap(entries: [])
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// spineIndex + 本地页码 → 全书绝对页码
|
||||
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
let entry = entries[idx]
|
||||
@@ -48,10 +42,9 @@ struct RDEPUBBookPageMap {
|
||||
return entry.absolutePageStart + localPageIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → spineIndex
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
|
||||
// 二分查找:entries 按 absolutePageStart 有序
|
||||
|
||||
var lo = 0, hi = entries.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
@@ -65,7 +58,6 @@ struct RDEPUBBookPageMap {
|
||||
return entries[lo - 1].spineIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → 本地页码(章节内偏移)
|
||||
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard let si = spineIndex(forAbsolutePage: absolutePage),
|
||||
let idx = indexBySpine[si] else { return nil }
|
||||
@@ -75,29 +67,23 @@ struct RDEPUBBookPageMap {
|
||||
return local
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的条目
|
||||
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
return entries[idx]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 在 entries 中的章节序号。
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
|
||||
indexBySpine[spineIndex]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的页数
|
||||
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
|
||||
entry(forSpineIndex: spineIndex)?.pageCount
|
||||
}
|
||||
|
||||
/// 全书总章节数
|
||||
var totalChapters: Int { entries.count }
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// Builder:从各章的 pageCount 逐步构建 BookPageMap
|
||||
struct Builder {
|
||||
|
||||
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
|
||||
|
||||
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
|
||||
@@ -105,7 +91,7 @@ struct RDEPUBBookPageMap {
|
||||
}
|
||||
|
||||
func build() -> RDEPUBBookPageMap {
|
||||
// 按 spineIndex 排序
|
||||
|
||||
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
|
||||
var entries: [RDEPUBBookPageMapEntry] = []
|
||||
var absolutePageStart = 0
|
||||
|
||||
+4
@@ -1,8 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
|
||||
let bookID: String
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+2
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterDataCache {
|
||||
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
|
||||
+120
-59
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -12,24 +14,22 @@ final class RDEPUBChapterLoader {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
// MARK: - 请求优先级
|
||||
|
||||
enum LoadPriority {
|
||||
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
||||
case preview // 设置预览:仅构建当前章并回调,不参与导航目标消费
|
||||
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
||||
|
||||
case navigation
|
||||
|
||||
case preview
|
||||
|
||||
case prefetch
|
||||
}
|
||||
|
||||
// MARK: - 主入口:加载单个章节
|
||||
|
||||
/// 在 chapterLoadQueue 上构建单章,完成后回调到主线程
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
// 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致)
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
DispatchQueue.main.async {
|
||||
@@ -38,14 +38,12 @@ final class RDEPUBChapterLoader {
|
||||
return
|
||||
}
|
||||
|
||||
// 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行,
|
||||
// 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程
|
||||
store.markBuilding(true)
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
|
||||
// 仅当内存级 pageCountCache 未命中时才查磁盘摘要
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
@@ -63,6 +61,7 @@ final class RDEPUBChapterLoader {
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
do {
|
||||
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
@@ -75,7 +74,6 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
|
||||
// 5. 回填缓存
|
||||
store.insertChapter(chapter)
|
||||
let pc = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
@@ -86,13 +84,12 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
|
||||
// 6. 按优先级处理完成逻辑
|
||||
switch priority {
|
||||
case .navigation:
|
||||
// 前台导航:检查是否有更新的导航目标(§14.4 取消语义)
|
||||
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
// 当前结果不再是用户目标,丢弃,转而加载新目标
|
||||
|
||||
store.markBuilding(false)
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
return
|
||||
@@ -109,8 +106,7 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
|
||||
case .prefetch:
|
||||
// 后台预取:仅回填缓存,标记预取目标完成
|
||||
// 不触发跳章,不检查导航目标队列
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
@@ -118,6 +114,7 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
@@ -127,14 +124,6 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
||||
|
||||
/// 约束:
|
||||
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
||||
/// - 不允许恢复整书 RDEPUBTextBook
|
||||
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
||||
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
||||
/// - 只在首次迁移且目标章未命中缓存时使用
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
@@ -193,8 +182,6 @@ final class RDEPUBChapterLoader {
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
@@ -210,7 +197,7 @@ final class RDEPUBChapterLoader {
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
@@ -224,7 +211,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 完整路径:无缓存,走全量渲染 + 分页 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try builder.buildChapter(
|
||||
@@ -245,8 +231,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
@@ -259,14 +243,14 @@ final class RDEPUBChapterLoader {
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title ?? ""
|
||||
let title = spineItem.title
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
let rawHTML = try requireHTMLString(parser, href: href)
|
||||
|
||||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: try requireHTMLString(parser, href: href),
|
||||
rawHTML: rawHTML,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
@@ -281,14 +265,24 @@ final class RDEPUBChapterLoader {
|
||||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
// 2. metadata 来源策略:
|
||||
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
||||
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
||||
let metadataSource = diskSummary?.pageMetadataList
|
||||
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
|
||||
let effectivePageRanges: [NSRange]
|
||||
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
|
||||
|
||||
if sanitizedCachedRanges.count == pageRanges.count {
|
||||
effectivePageRanges = sanitizedCachedRanges
|
||||
metadataSource = diskSummary?.pageMetadataList
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)"
|
||||
)
|
||||
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
|
||||
metadataSource = nil
|
||||
}
|
||||
|
||||
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: pageRanges,
|
||||
pageRanges: effectivePageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
@@ -296,35 +290,39 @@ final class RDEPUBChapterLoader {
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
// 4. 构建 layouter(用于后续可能的重新分页场景)
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
// 5. 构建 chapterOffsetMap
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset }
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: typesetString.string
|
||||
),
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pageRanges: effectivePageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
||||
|
||||
|
||||
private func buildPagesFromRanges(
|
||||
pageRanges: [NSRange],
|
||||
typesetString: NSAttributedString,
|
||||
@@ -338,10 +336,10 @@ final class RDEPUBChapterLoader {
|
||||
let pageContent = typesetString.attributedSubstring(from: range)
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
// 从摘要缓存恢复完整 metadata
|
||||
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
// 无缓存 metadata,从 attributedString 属性推断
|
||||
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
@@ -366,7 +364,21 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
||||
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
|
||||
guard contentLength > 0 else { return [] }
|
||||
|
||||
return pageRanges.compactMap { range in
|
||||
guard range.location >= 0, range.location < contentLength else {
|
||||
return nil
|
||||
}
|
||||
let maxLength = contentLength - range.location
|
||||
let clampedLength = min(max(range.length, 0), maxLength)
|
||||
guard clampedLength > 0 else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: range.location, length: clampedLength)
|
||||
}
|
||||
}
|
||||
|
||||
private func inferPageMetadata(
|
||||
from string: NSAttributedString,
|
||||
@@ -431,8 +443,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
@@ -448,17 +458,25 @@ final class RDEPUBChapterLoader {
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
|
||||
cfiMap: chapter.cfiMap ?? makeCFIMap(
|
||||
href: chapter.href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
|
||||
chapterText: chapter.attributedContent.string
|
||||
),
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
// 回填磁盘摘要(P2 阶段生效)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
@@ -479,13 +497,10 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
||||
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
|
||||
let renderSignature = [
|
||||
@@ -521,11 +536,57 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func makeCFIMap(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
fragmentOffsets: [String: Int],
|
||||
rawHTML: String?,
|
||||
chapterText: String
|
||||
) -> RDEPUBCFIMap {
|
||||
if let rawHTML {
|
||||
return RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
let domPaths: [String: RDEPUBCFIPath] = [:]
|
||||
let markers = fragmentOffsets
|
||||
.sorted { $0.value < $1.value }
|
||||
.map { fragmentID, offset in
|
||||
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
return RDEPUBCFIMarker(
|
||||
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
return RDEPUBCFIMap(
|
||||
href: href,
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
|
||||
fragmentPathMap: domPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
|
||||
case missingParser
|
||||
|
||||
case emptyChapter(spineIndex: Int)
|
||||
|
||||
case emptyChapterHref(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
|
||||
+5
-8
@@ -1,17 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// 章节级位置模型——单章内的精确定位
|
||||
/// 取代旧版 RDEPUBLocation 的全局 progression 方式
|
||||
public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
/// 章节在 spine 中的索引
|
||||
|
||||
public var spineIndex: Int
|
||||
/// 章内字符偏移(从章首算起,0-based)
|
||||
|
||||
public var chapterOffset: Int
|
||||
/// HTML fragment ID(如章节内锚点 #section1)
|
||||
|
||||
public var fragmentID: String?
|
||||
/// 章内 progression(可选,fragmentID 优先时为 nil)
|
||||
|
||||
public var progressionInChapter: Double?
|
||||
/// schema 版本:1=粗估降级, 2=精确值
|
||||
|
||||
public var schemaVersion: Int
|
||||
|
||||
public init(
|
||||
@@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
self.schemaVersion = schemaVersion
|
||||
}
|
||||
|
||||
/// 粗估结果标记:schemaVersion == 1 表示 chapterOffset 由 progression 粗估得来
|
||||
var isFallbackEstimate: Bool { schemaVersion == 1 }
|
||||
}
|
||||
+22
-3
@@ -1,16 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterOffsetMap {
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let pageStartOffsets: [Int]
|
||||
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
/// fragmentID -> 章内字符偏移
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
|
||||
let chapterText: String?
|
||||
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
/// 章内字符偏移 -> 章内页码(从 0 开始)
|
||||
func chapterOffset(forCFI rawCFI: String?) -> Int? {
|
||||
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
|
||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
||||
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
|
||||
return RDEPUBCFIRecoveryEngine.recover(
|
||||
cfi: cfi,
|
||||
cfiMap: cfiMap,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
)?.chapterOffset
|
||||
}
|
||||
|
||||
func pageIndex(forChapterOffset offset: Int) -> Int? {
|
||||
for i in 0..<pageStartOffsets.count {
|
||||
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
|
||||
@@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-47
@@ -2,50 +2,36 @@ import UIKit
|
||||
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
// MARK: - 子缓存
|
||||
|
||||
/// 章节运行时主缓存(等价 WXRead chapterDataCache)
|
||||
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||
|
||||
/// 轻量分页结构缓存(等价 WXRead pageCountCache)
|
||||
private let pageCountCache = RDEPUBPageCountCache()
|
||||
|
||||
/// 图片缓存(独立 NSCache,等价 WXRead imageCache)
|
||||
let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
||||
/// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成
|
||||
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
|
||||
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// MARK: - 窗口状态
|
||||
|
||||
/// 当前章 spineIndex
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
/// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开)
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
|
||||
|
||||
/// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
private var pendingNavigationTarget: Int?
|
||||
|
||||
private let navigationLock = NSLock()
|
||||
|
||||
/// 后台预取目标集合(±1 相邻章预取)
|
||||
/// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章
|
||||
private var pendingPrefetchTargets: Set<Int> = []
|
||||
|
||||
private let prefetchLock = NSLock()
|
||||
|
||||
/// 是否有章节正在构建中
|
||||
private(set) var isBuilding: Bool = false
|
||||
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
@@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||
}
|
||||
|
||||
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
return chapterDataCache[spineIndex]
|
||||
}
|
||||
@@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
return pageCountCache[key]
|
||||
}
|
||||
|
||||
// MARK: - 缓存插入
|
||||
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||
chapterDataCache[chapter.spineIndex] = chapter
|
||||
}
|
||||
@@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
pageCountCache[key] = pc
|
||||
}
|
||||
|
||||
// MARK: - 窗口管理
|
||||
|
||||
/// 设定当前章,自动计算按半径展开的窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||
currentSpineIndex = spineIndex
|
||||
let radius = max(0, windowRadius)
|
||||
@@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore {
|
||||
windowSpineIndices = Array(lowerBound...upperBound)
|
||||
}
|
||||
|
||||
/// 返回窗口外、应该淘汰的 spineIndex
|
||||
func evictableSpineIndices() -> [Int] {
|
||||
let windowSet = Set(windowSpineIndices)
|
||||
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - 淘汰
|
||||
|
||||
func evict(spineIndex: Int) {
|
||||
chapterDataCache.remove(spineIndex: spineIndex)
|
||||
pageCountCache.remove(forSpineIndex: spineIndex)
|
||||
@@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore {
|
||||
if let ch = currentChapter {
|
||||
chapterDataCache[current] = ch
|
||||
}
|
||||
// WXRead 语义:内存警告时 pageCountCache 全量清空
|
||||
|
||||
pageCountCache.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - 内存警告
|
||||
|
||||
func handleMemoryWarning() {
|
||||
evictAllExceptCurrent()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
|
||||
// MARK: - 前台导航请求管理(§14.4 取消语义)
|
||||
|
||||
/// 注册前台导航目标(用户主动跳章时调用)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
func setNavigationTarget(spineIndex: Int) {
|
||||
navigationLock.lock()
|
||||
pendingNavigationTarget = spineIndex
|
||||
navigationLock.unlock()
|
||||
}
|
||||
|
||||
/// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标)
|
||||
func consumeNavigationTarget() -> Int? {
|
||||
navigationLock.lock()
|
||||
let target = pendingNavigationTarget
|
||||
@@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore {
|
||||
return target
|
||||
}
|
||||
|
||||
// MARK: - 后台预取请求管理
|
||||
|
||||
/// 注册后台预取目标(±1 相邻章预取时调用)
|
||||
/// 预取不抢占前台导航通道
|
||||
func addPrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.insert(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 标记预取目标已完成
|
||||
func removePrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.remove(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 清空所有预取目标(切章时调用,旧预取结果不再需要)
|
||||
func clearPrefetchTargets() {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.removeAll()
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 检查是否有待处理的预取目标
|
||||
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
|
||||
prefetchLock.lock()
|
||||
let has = pendingPrefetchTargets.contains(spineIndex)
|
||||
@@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - P1: 排版参数变化后整体失效(§8.5)
|
||||
|
||||
func invalidateAllForSettingsChange() {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
|
||||
+24
-21
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
@@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - 写入(异步)
|
||||
|
||||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.sync {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待此前已排队的异步写入全部落盘。
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data: Data
|
||||
@@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
} 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)")
|
||||
@@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
|
||||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||||
/// 同步方法,应在后台线程调用。
|
||||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||||
summaries: [Int: RDEPUBChapterSummary],
|
||||
mapBuilder: RDEPUBBookPageMap.Builder
|
||||
@@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return (summaries, mapBuilder)
|
||||
}
|
||||
|
||||
/// 检查指定缓存键列表是否全部有对应的磁盘摘要。
|
||||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
for key in keys {
|
||||
if read(for: key) == nil {
|
||||
@@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
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)
|
||||
@@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
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)
|
||||
@@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
|
||||
struct RDEPUBChapterSummary: Codable {
|
||||
|
||||
let pageRanges: [RangeData]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let schemaVersion: Int
|
||||
|
||||
let chapterContentHash: String
|
||||
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 6
|
||||
static let currentSchemaVersion = 9
|
||||
|
||||
struct RangeData: Codable {
|
||||
|
||||
let location: Int
|
||||
|
||||
let length: Int
|
||||
|
||||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||||
}
|
||||
|
||||
struct PageMetadataSummary: Codable {
|
||||
|
||||
let breakReason: String
|
||||
|
||||
let attachmentRanges: [RangeData]
|
||||
|
||||
let attachmentKinds: [String]
|
||||
|
||||
let blockKinds: [String]
|
||||
|
||||
let semanticHints: [String]
|
||||
|
||||
let attachmentPlacements: [String]
|
||||
|
||||
let trailingFragmentID: String?
|
||||
|
||||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||||
|
||||
+13
-46
@@ -1,14 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private let loader: RDEPUBChapterLoader
|
||||
|
||||
/// 当前窗口快照
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
/// 窗口切换回调
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
|
||||
@@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
/// 章节加载完成后恢复位置用
|
||||
private var restoreChapterOffset: Int?
|
||||
|
||||
// MARK: - 打开书籍
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
store.setCurrentChapter(
|
||||
@@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
)
|
||||
self.restoreChapterOffset = restoreChapterOffset
|
||||
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: targetSpineIndex)
|
||||
// 清空旧预取目标
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
// 加载目标章(前台导航优先级)
|
||||
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
}
|
||||
|
||||
/// 加载章节,如果当前章节失败则自动尝试下一个可渲染的章节
|
||||
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
|
||||
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
@@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
#endif
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
self.store.setCurrentChapter(
|
||||
@@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.store.setNavigationTarget(spineIndex: nextIndex)
|
||||
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
} else {
|
||||
// 所有章节都不可渲染
|
||||
|
||||
self.isSwitchingChapter = false
|
||||
self.handle(error: error)
|
||||
}
|
||||
@@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 构建窗口快照
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
#if DEBUG
|
||||
@@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
onSnapshotChanged?(snapshot)
|
||||
isApplyingSnapshot = false
|
||||
|
||||
// 首次打开时恢复到指定 chapterOffset
|
||||
if let offset = restoreChapterOffset,
|
||||
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
|
||||
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||
let targetPage = snapshot.anchorPageOffset + pageIndex
|
||||
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
|
||||
} else if snapshot.pageCount > 0 {
|
||||
// 首次打开且没有恢复位置时,必须显式落到当前章首屏。
|
||||
// reloadData() 内部 switchReaderDisplayType 已将 currentPage 从 -1 置为 0,
|
||||
// 但 0 不一定是目标章的起始页(anchorPageOffset),仍需显式 transition。
|
||||
|
||||
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
|
||||
}
|
||||
restoreChapterOffset = nil
|
||||
|
||||
// 预取窗口内尚未加载的章节
|
||||
prefetchAdjacent(current: current)
|
||||
}
|
||||
|
||||
// MARK: - 预取(后台优先级,不抢占前台导航通道)
|
||||
|
||||
private func prefetchAdjacent(current: Int) {
|
||||
for spineIndex in store.windowSpineIndices where spineIndex != current {
|
||||
guard store.chapterData(for: spineIndex) == nil else { continue }
|
||||
@@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻章
|
||||
|
||||
/// 到达章末,翻到下一章
|
||||
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex else { return }
|
||||
let next = current + 1
|
||||
@@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
flipToChapter(spineIndex: next, completion: completion)
|
||||
}
|
||||
|
||||
/// 到达章首,翻到上一章
|
||||
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex, current > 0 else { return }
|
||||
flipToChapter(spineIndex: current - 1, completion: completion)
|
||||
}
|
||||
|
||||
/// 跳转到指定章节(目录/书签/搜索)
|
||||
func flipToChapter(
|
||||
spineIndex: Int,
|
||||
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
|
||||
) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: spineIndex)
|
||||
// 清空后台预取目标
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
// 标记切章进行中
|
||||
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 先淘汰旧窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
@@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
// 如果目标章已在缓存中,直接构建快照
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
buildSnapshotAroundCurrent(chapter: cached)
|
||||
isSwitchingChapter = false
|
||||
@@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// 未命中缓存,走加载链路(前台导航优先级)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
self.isSwitchingChapter = false
|
||||
@@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 刷新快照(预取完成后调用)
|
||||
|
||||
func refreshSnapshot() {
|
||||
guard let current = store.currentSpineIndex,
|
||||
let currentChapter = store.chapterData(for: current) else { return }
|
||||
|
||||
// 空闲门槛检查
|
||||
guard isReaderIdle() else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshSnapshot()
|
||||
@@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - P1: 翻章后维护窗口
|
||||
|
||||
/// 当前章常驻,预取新的相邻章
|
||||
func maintainWindow(afterMovingTo spineIndex: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 淘汰窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
@@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
prefetchAdjacent(current: spineIndex)
|
||||
}
|
||||
|
||||
// MARK: - 内部辅助
|
||||
|
||||
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")
|
||||
@@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切章进行中标记
|
||||
private var isSwitchingChapter: Bool = false
|
||||
/// 应用快照进行中标记
|
||||
|
||||
private var isApplyingSnapshot: Bool = false
|
||||
}
|
||||
|
||||
+1
-16
@@ -1,26 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
/// 窗口中的章节(有序)
|
||||
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||
/// 窗口内页码不写回 RDEPUBTextPage 模型;
|
||||
/// flattenedPages 的数组下标就是窗口内连续页码(从 0 开始)。
|
||||
let flattenedPages: [RDEPUBTextPage]
|
||||
|
||||
/// 当前章在 chapters 数组中的索引
|
||||
let anchorChapterIndex: Int
|
||||
|
||||
/// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号)
|
||||
let anchorPageOffset: Int
|
||||
|
||||
/// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射
|
||||
let windowStartSpineIndex: Int
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// 从章节窗口构建快照
|
||||
static func from(
|
||||
chapters: [RDEPUBRuntimeChapter],
|
||||
anchorSpineIndex: Int
|
||||
@@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
|
||||
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
|
||||
|
||||
// 展平页数组
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in sortedChapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
@@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节
|
||||
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
var offset = 0
|
||||
for ch in chapters {
|
||||
@@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex
|
||||
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||
}
|
||||
|
||||
/// 总页数(窗口内)
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBPageCountCache {
|
||||
|
||||
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBResolvedPage {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let chapter: RDEPUBRuntimeChapter
|
||||
|
||||
let chapterIndex: Int
|
||||
}
|
||||
|
||||
final class RDEPUBPageResolver {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
|
||||
|
||||
+3
-7
@@ -1,26 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBRuntimeChapter {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
|
||||
/// 原始富文本(可按策略释放,不强制常驻)
|
||||
var sourceAttributedString: NSAttributedString?
|
||||
|
||||
/// 排版后富文本
|
||||
let typesetAttributedString: NSAttributedString
|
||||
|
||||
/// 排版器
|
||||
let layouter: RDEPUBTextLayouter
|
||||
|
||||
/// 页范围
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
/// 页面数组
|
||||
let pages: [RDEPUBTextPage]
|
||||
|
||||
/// 章节偏移映射
|
||||
let chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
|
||||
init(
|
||||
@@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter {
|
||||
self.chapterOffsetMap = chapterOffsetMap
|
||||
}
|
||||
|
||||
/// 释放 sourceAttributedString 以降低内存
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
|
||||
+5
@@ -1,9 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRuntimePageCount {
|
||||
|
||||
let cacheKey: RDEPUBChapterCacheKey
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let renderSignature: String
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
|
||||
var sha256Hex: String {
|
||||
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// 后台覆盖段:表示某一段章节范围的稳定页码覆盖
|
||||
struct RDEPUBBackgroundCoverageSegment {
|
||||
/// 段的下界 spineIndex
|
||||
|
||||
let lowerSpineIndex: Int
|
||||
/// 段的上界 spineIndex
|
||||
|
||||
let upperSpineIndex: Int
|
||||
/// 页图数据
|
||||
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
/// 已解析的 spineIndex 集合
|
||||
|
||||
let resolvedSpineIndices: Set<Int>
|
||||
/// 创建时间
|
||||
|
||||
let generatedAt: CFAbsoluteTime
|
||||
/// 渲染签名
|
||||
|
||||
let renderSignature: String
|
||||
/// 预估内存占用(字节)
|
||||
|
||||
let estimatedMemoryBytes: Int
|
||||
|
||||
/// 是否包含指定 spineIndex
|
||||
func contains(spineIndex: Int) -> Bool {
|
||||
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
|
||||
}
|
||||
|
||||
/// 与指定 spineIndex 的距离
|
||||
func distance(to spineIndex: Int) -> Int {
|
||||
if contains(spineIndex: spineIndex) { return 0 }
|
||||
return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖存储策略
|
||||
struct RDEPUBBackgroundCoverageStorePolicy {
|
||||
/// 最大常驻段数
|
||||
|
||||
let maxResidentSegments: Int
|
||||
/// 单段最大章节数
|
||||
|
||||
let maxChaptersPerSegment: Int
|
||||
/// 内存预算(字节)
|
||||
|
||||
let memoryBudgetBytes: Int
|
||||
|
||||
/// 默认策略
|
||||
static let `default` = RDEPUBBackgroundCoverageStorePolicy(
|
||||
maxResidentSegments: 8,
|
||||
maxChaptersPerSegment: 256,
|
||||
memoryBudgetBytes: 8 * 1024 * 1024 // 8MB
|
||||
memoryBudgetBytes: 8 * 1024 * 1024
|
||||
)
|
||||
}
|
||||
|
||||
/// 后台覆盖存储管理器
|
||||
final class RDEPUBBackgroundCoverageStore {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 存储策略
|
||||
private let policy: RDEPUBBackgroundCoverageStorePolicy
|
||||
|
||||
/// 存储的段列表
|
||||
private var segments: [RDEPUBBackgroundCoverageSegment] = []
|
||||
|
||||
/// 当前总内存占用
|
||||
private var currentMemoryBytes: Int = 0
|
||||
|
||||
/// 最后访问时间(用于 LRU 淘汰)
|
||||
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
|
||||
@@ -67,12 +58,10 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
/// 添加段
|
||||
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
|
||||
// 检查是否需要淘汰
|
||||
|
||||
evictIfNeeded(forNewSegment: segment)
|
||||
|
||||
// 检查是否与现有段重叠,合并或替换
|
||||
var merged = false
|
||||
for (index, existing) in segments.enumerated() {
|
||||
if canMerge(existing, segment) {
|
||||
@@ -94,7 +83,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
currentMemoryBytes += segment.estimatedMemoryBytes
|
||||
}
|
||||
|
||||
// 更新访问时间
|
||||
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -103,7 +91,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 查找覆盖指定 spineIndex 的段
|
||||
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
|
||||
if let segment {
|
||||
@@ -112,7 +99,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return segment
|
||||
}
|
||||
|
||||
/// 查找覆盖指定 spineIndex 集合的段
|
||||
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { segment in
|
||||
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
|
||||
@@ -123,19 +109,16 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return segment
|
||||
}
|
||||
|
||||
/// 获取所有段
|
||||
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
|
||||
segments
|
||||
}
|
||||
|
||||
/// 清除所有段
|
||||
func clearAll() {
|
||||
segments.removeAll()
|
||||
currentMemoryBytes = 0
|
||||
lastAccessTime.removeAll()
|
||||
}
|
||||
|
||||
/// 清除冷区段(不覆盖当前阅读位置和保护区的段)
|
||||
func clearColdSegments(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
@@ -151,7 +134,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理内存警告
|
||||
func handleMemoryWarning(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
@@ -161,15 +143,13 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
"memory warning: clearing cold segments, current=\(currentMemoryBytes)B"
|
||||
)
|
||||
|
||||
// 第一步:清除冷区段
|
||||
clearColdSegments(
|
||||
activeWindowSpineIndices: activeWindowSpineIndices,
|
||||
protectedSpineIndices: protectedSpineIndices
|
||||
)
|
||||
|
||||
// 如果仍然超限,清除更多段
|
||||
if currentMemoryBytes > policy.memoryBudgetBytes {
|
||||
// 按距离排序,清除最远的段
|
||||
|
||||
let sorted = segments.sorted { lhs, rhs in
|
||||
let lhsDistance = lhs.resolvedSpineIndices.map { idx in
|
||||
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
|
||||
@@ -194,24 +174,20 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否需要淘汰
|
||||
private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
|
||||
// 检查段数限制
|
||||
|
||||
while segments.count >= policy.maxResidentSegments {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
|
||||
// 检查内存限制
|
||||
while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
}
|
||||
|
||||
/// 淘汰最近最少使用的段
|
||||
private func evictLeastRecentlyUsed() {
|
||||
guard !segments.isEmpty else { return }
|
||||
|
||||
// 找到最久未访问的段
|
||||
var oldestTime = CFAbsoluteTimeGetCurrent()
|
||||
var oldestIndex = 0
|
||||
for (index, segment) in segments.enumerated() {
|
||||
@@ -232,33 +208,27 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查两个段是否可以合并
|
||||
private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
|
||||
// 渲染签名必须相同
|
||||
|
||||
guard lhs.renderSignature == rhs.renderSignature else { return false }
|
||||
|
||||
// 检查是否重叠或相邻
|
||||
let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
|
||||
rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
|
||||
return overlap
|
||||
}
|
||||
|
||||
/// 合并两个段
|
||||
private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
|
||||
let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
|
||||
let newChapterCount = newUpper - newLower + 1
|
||||
|
||||
// 检查合并后是否超过单段最大章节数
|
||||
if newChapterCount > policy.maxChaptersPerSegment {
|
||||
// 按当前阅读位置切分
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 合并 resolvedSpineIndices
|
||||
let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
|
||||
|
||||
// 合并页图
|
||||
let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
|
||||
let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
|
||||
let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
|
||||
@@ -274,7 +244,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 合并两个页图
|
||||
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
|
||||
@@ -300,7 +269,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/// 估算内存占用
|
||||
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
|
||||
256 + pageMap.entries.count * 96 + resolvedCount * 16
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// 后台补全优先级策略
|
||||
///
|
||||
/// 控制后台元数据解析的优先级排序,确保当前阅读区段优先补全
|
||||
struct RDEPUBBackgroundPriorityPolicy {
|
||||
/// 热区半径:当前章节附近的范围
|
||||
|
||||
let hotRadius: Int
|
||||
/// 温区半径:远距跳转锚点附近的范围
|
||||
|
||||
let warmRadius: Int
|
||||
/// 最大温区跳转锚点数量
|
||||
|
||||
let maxWarmJumpAnchors: Int
|
||||
/// 冷区份额:每轮补全中冷区任务的比例
|
||||
|
||||
let coldLaneShare: Double
|
||||
|
||||
/// 默认策略
|
||||
static let `default` = RDEPUBBackgroundPriorityPolicy(
|
||||
hotRadius: 24,
|
||||
warmRadius: 96,
|
||||
@@ -21,7 +17,6 @@ struct RDEPUBBackgroundPriorityPolicy {
|
||||
coldLaneShare: 0.15
|
||||
)
|
||||
|
||||
/// 根据总章节数动态计算策略
|
||||
static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
|
||||
let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
|
||||
let warmRadius = min(max(hotRadius * 3, 32), 192)
|
||||
@@ -34,7 +29,6 @@ struct RDEPUBBackgroundPriorityPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先级带
|
||||
enum RDEPUBPriorityBand: Int, Comparable {
|
||||
case hot = 0
|
||||
case warmPrimary = 1
|
||||
@@ -46,39 +40,32 @@ enum RDEPUBPriorityBand: Int, Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 温区跳转锚点
|
||||
struct RDEPUBWarmJumpAnchor {
|
||||
let spineIndex: Int
|
||||
let timestamp: CFAbsoluteTime
|
||||
let sequenceNumber: Int
|
||||
}
|
||||
|
||||
/// 元数据解析工作项
|
||||
struct RDEPUBMetadataParseWorkItem {
|
||||
let spineIndex: Int
|
||||
let generation: Int
|
||||
let priorityBand: RDEPUBPriorityBand
|
||||
|
||||
/// 排序键
|
||||
var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
|
||||
(priorityBand.rawValue, 0, 0, spineIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台补全优先级管理器
|
||||
final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 当前策略
|
||||
private(set) var policy: RDEPUBBackgroundPriorityPolicy
|
||||
|
||||
/// 温区跳转锚点列表(按时间倒序)
|
||||
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
|
||||
|
||||
/// 当前 generation
|
||||
private(set) var currentGeneration: Int = 0
|
||||
|
||||
/// 冷区游标
|
||||
private var coldCursor: Int = 0
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -86,12 +73,10 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
self.policy = .default
|
||||
}
|
||||
|
||||
/// 更新策略
|
||||
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
|
||||
policy = newPolicy
|
||||
}
|
||||
|
||||
/// 添加温区跳转锚点
|
||||
func addWarmAnchor(spineIndex: Int) {
|
||||
let anchor = RDEPUBWarmJumpAnchor(
|
||||
spineIndex: spineIndex,
|
||||
@@ -101,13 +86,12 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
warmAnchors.insert(anchor, at: 0)
|
||||
|
||||
// 保留最近 N 个锚点
|
||||
if warmAnchors.count > policy.maxWarmJumpAnchors {
|
||||
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
|
||||
}
|
||||
|
||||
currentGeneration += 1
|
||||
coldCursor = 0 // 重置冷区游标
|
||||
coldCursor = 0
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PriorityManager",
|
||||
@@ -115,7 +99,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成优先级排序的 spineIndex 列表
|
||||
func makeMetadataPriorityOrder(
|
||||
allBuildableIndices: [Int],
|
||||
currentSpineIndex: Int?,
|
||||
@@ -124,7 +107,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
guard !uncachedIndices.isEmpty else { return [] }
|
||||
|
||||
// 为每个 spineIndex 计算优先级带
|
||||
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
|
||||
let band = classifySpineIndex(
|
||||
spineIndex: spineIndex,
|
||||
@@ -133,33 +115,29 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
return (spineIndex, band)
|
||||
}
|
||||
|
||||
// 排序
|
||||
let sorted = items.sorted { lhs, rhs in
|
||||
// 先按优先级带排序
|
||||
|
||||
if lhs.band != rhs.band {
|
||||
return lhs.band < rhs.band
|
||||
}
|
||||
|
||||
// 同一带内按距离排序
|
||||
let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
|
||||
let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
|
||||
if lhsDistanceToCurrent != rhsDistanceToCurrent {
|
||||
return lhsDistanceToCurrent < rhsDistanceToCurrent
|
||||
}
|
||||
|
||||
// 距离相同时按 spineIndex 排序
|
||||
return lhs.spineIndex < rhs.spineIndex
|
||||
}
|
||||
|
||||
return sorted.map { $0.spineIndex }
|
||||
}
|
||||
|
||||
/// 分类 spineIndex 到优先级带
|
||||
private func classifySpineIndex(
|
||||
spineIndex: Int,
|
||||
currentSpineIndex: Int?
|
||||
) -> RDEPUBPriorityBand {
|
||||
// 检查是否在热区
|
||||
|
||||
if let current = currentSpineIndex {
|
||||
let distance = abs(spineIndex - current)
|
||||
if distance <= policy.hotRadius {
|
||||
@@ -167,7 +145,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否在温区
|
||||
for (index, anchor) in warmAnchors.enumerated() {
|
||||
let distance = abs(spineIndex - anchor.spineIndex)
|
||||
if distance <= policy.warmRadius {
|
||||
@@ -175,16 +152,13 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 冷区
|
||||
return .cold
|
||||
}
|
||||
|
||||
/// 获取当前温区锚点(用于后台任务调度)
|
||||
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
|
||||
warmAnchors
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
func reset() {
|
||||
warmAnchors.removeAll()
|
||||
currentGeneration = 0
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// 远距跳转会话:保护前台阅读窗口不被后台页图覆盖。
|
||||
///
|
||||
/// 当用户执行远距跳转(目录、书签、搜索)后创建,在保护期内:
|
||||
/// - 不允许任何不覆盖保护区的后台页图接管前台
|
||||
/// - 后台补全围绕当前阅读区段优先
|
||||
struct RDEPUBJumpSession {
|
||||
/// 跳转目标的 spineIndex
|
||||
|
||||
let anchorSpineIndex: Int
|
||||
/// 创建时间
|
||||
|
||||
let createdAt: CFAbsoluteTime
|
||||
/// 受保护的 spineIndex 集合
|
||||
|
||||
let protectedSpineIndices: Set<Int>
|
||||
/// 序列号,用于区分多次跳转
|
||||
|
||||
let sequenceNumber: Int
|
||||
/// 过期时间
|
||||
|
||||
let expiresAt: CFAbsoluteTime
|
||||
/// 跳转原因
|
||||
|
||||
let reason: Reason
|
||||
|
||||
/// 跳转原因枚举
|
||||
enum Reason {
|
||||
case tableOfContentsJump
|
||||
case bookmarkJump
|
||||
case searchJump
|
||||
}
|
||||
|
||||
/// 结束条件枚举
|
||||
enum EndReason {
|
||||
/// 候选页图已完整覆盖保护区
|
||||
|
||||
case coverageComplete
|
||||
/// 用户连续翻页离开保护区
|
||||
|
||||
case navigatedAway
|
||||
/// 超时
|
||||
|
||||
case timeout
|
||||
/// 被新的跳转取代
|
||||
|
||||
case superseded
|
||||
}
|
||||
}
|
||||
|
||||
/// JumpSession 策略配置
|
||||
public struct RDEPUBJumpSessionPolicy: Equatable {
|
||||
/// 连续翻页离开保护区的阈值
|
||||
|
||||
public let exitPageThreshold: Int
|
||||
/// 跳转超时时间
|
||||
|
||||
public let timeout: TimeInterval
|
||||
/// 空闲宽限期
|
||||
|
||||
public let idleGracePeriod: TimeInterval
|
||||
/// 保护区相邻章节半径
|
||||
|
||||
public let protectedNeighborRadius: Int
|
||||
|
||||
/// 默认策略
|
||||
public static let `default` = RDEPUBJumpSessionPolicy(
|
||||
exitPageThreshold: 6,
|
||||
timeout: 20,
|
||||
@@ -71,26 +62,20 @@ public struct RDEPUBJumpSessionPolicy: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// JumpSession 管理器
|
||||
final class RDEPUBJumpSessionManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 当前活跃的 JumpSession
|
||||
private(set) var activeSession: RDEPUBJumpSession?
|
||||
|
||||
/// 全局序列号
|
||||
private var nextSequenceNumber: Int = 0
|
||||
|
||||
/// 连续翻页计数器
|
||||
private var consecutivePageCount: Int = 0
|
||||
|
||||
/// 上次翻页方向
|
||||
private var lastPageDirection: PageDirection?
|
||||
|
||||
/// 上次用户活动时间
|
||||
private var lastActivityTime: CFAbsoluteTime = 0
|
||||
|
||||
/// 页面方向
|
||||
enum PageDirection {
|
||||
case forward
|
||||
case backward
|
||||
@@ -100,7 +85,6 @@ final class RDEPUBJumpSessionManager {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 创建新的 JumpSession
|
||||
@discardableResult
|
||||
func createSession(
|
||||
anchorSpineIndex: Int,
|
||||
@@ -110,7 +94,6 @@ final class RDEPUBJumpSessionManager {
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// 计算保护区
|
||||
var protectedIndices: Set<Int> = [anchorSpineIndex]
|
||||
for offset in 1...policy.protectedNeighborRadius {
|
||||
let lower = anchorSpineIndex - offset
|
||||
@@ -146,7 +129,6 @@ final class RDEPUBJumpSessionManager {
|
||||
return session
|
||||
}
|
||||
|
||||
/// 记录用户翻页
|
||||
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
|
||||
guard activeSession != nil else { return }
|
||||
|
||||
@@ -161,29 +143,24 @@ final class RDEPUBJumpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许页图接管
|
||||
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
|
||||
guard let session = activeSession else {
|
||||
return true // 没有活跃 Session,允许接管
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查候选页图是否覆盖保护区
|
||||
let protectedIndices = session.protectedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
|
||||
Double(protectedIndices.count)
|
||||
|
||||
// 必须覆盖至少 80% 的保护区
|
||||
return coverageRatio >= 0.8
|
||||
}
|
||||
|
||||
/// 检查是否应该结束 Session
|
||||
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
|
||||
guard let session = activeSession else { return nil }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
|
||||
// 1. 检查超时
|
||||
if now >= session.expiresAt {
|
||||
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -194,7 +171,6 @@ final class RDEPUBJumpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否离开保护区
|
||||
if !session.protectedSpineIndices.contains(currentSpineIndex) {
|
||||
if consecutivePageCount >= policy.exitPageThreshold {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -204,14 +180,13 @@ final class RDEPUBJumpSessionManager {
|
||||
return .navigatedAway
|
||||
}
|
||||
} else {
|
||||
// 在保护区内,重置连续翻页计数
|
||||
|
||||
consecutivePageCount = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 结束当前 Session
|
||||
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
|
||||
guard let session = activeSession else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -223,7 +198,6 @@ final class RDEPUBJumpSessionManager {
|
||||
lastPageDirection = nil
|
||||
}
|
||||
|
||||
/// 清除 Session(用于重新加载等场景)
|
||||
func clearSession() {
|
||||
activeSession = nil
|
||||
consecutivePageCount = 0
|
||||
|
||||
+11
-27
@@ -1,33 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// 页图接管决策
|
||||
enum RDEPUBPageMapTakeoverDecision {
|
||||
/// 保持当前窗口不变
|
||||
|
||||
case keepCurrentWindow
|
||||
/// 扩窗:将候选段合并到当前窗口
|
||||
|
||||
case expandWindow(RDEPUBBackgroundCoverageSegment)
|
||||
/// 分段替换:用候选段替换当前窗口的部分内容
|
||||
|
||||
case segmentReplace(RDEPUBBackgroundCoverageSegment)
|
||||
/// 全量替换:用完整页图替换当前窗口
|
||||
|
||||
case fullReplace(RDEPUBBookPageMap)
|
||||
}
|
||||
|
||||
/// 页图协调器:负责判断后台解析结果是否可以接管前台窗口
|
||||
final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 判断是否可以接管前台窗口
|
||||
func evaluateTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap?,
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment?,
|
||||
currentWindow: RDEPUBBookPageMap?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
// 如果没有当前窗口,允许接管
|
||||
|
||||
guard let currentWindow else {
|
||||
if let candidatePageMap {
|
||||
return .fullReplace(candidatePageMap)
|
||||
@@ -35,7 +33,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
// 获取当前阅读位置
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
|
||||
@@ -46,11 +43,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}) ?? 0
|
||||
|
||||
// 检查 JumpSession 保护
|
||||
if let jumpSession {
|
||||
let protectedIndices = jumpSession.protectedSpineIndices
|
||||
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
|
||||
// 在保护区内,检查候选是否覆盖保护区
|
||||
|
||||
if let candidateSegment {
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
|
||||
@@ -66,7 +62,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查边界章节覆盖
|
||||
if let currentSpineIndex {
|
||||
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
|
||||
|
||||
@@ -85,7 +80,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查渲染签名一致性
|
||||
if let candidateSegment {
|
||||
let currentRenderSignature = context.currentRenderSignature()
|
||||
if candidateSegment.renderSignature != currentRenderSignature {
|
||||
@@ -97,7 +91,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 评估接管类型
|
||||
if let candidateSegment {
|
||||
return evaluateSegmentTakeover(
|
||||
candidateSegment: candidateSegment,
|
||||
@@ -119,7 +112,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 评估分段接管
|
||||
private func evaluateSegmentTakeover(
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
@@ -129,14 +121,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
|
||||
// 检查是否覆盖当前阅读位置
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否覆盖相邻章节
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
@@ -146,21 +136,20 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否与当前窗口连续
|
||||
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
|
||||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
|
||||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
|
||||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
|
||||
|
||||
if isContinuous {
|
||||
// 连续,可以扩窗
|
||||
|
||||
return .expandWindow(candidateSegment)
|
||||
} else {
|
||||
// 不连续,检查是否覆盖当前窗口的大部分
|
||||
|
||||
let overlap = currentIndices.intersection(candidateIndices)
|
||||
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
|
||||
if overlapRatio > 0.5 {
|
||||
// 覆盖大部分,可以替换
|
||||
|
||||
return .segmentReplace(candidateSegment)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +157,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 评估全量页图接管
|
||||
private func evaluateFullPageMapTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
@@ -177,14 +165,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||
|
||||
// 检查是否覆盖当前阅读位置
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否覆盖相邻章节
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
@@ -194,7 +180,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否完整覆盖
|
||||
let isComplete = candidateIndices.count >= currentWindow.entries.count
|
||||
if isComplete {
|
||||
return .fullReplace(candidatePageMap)
|
||||
@@ -203,7 +188,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 生成保护区域的 spineIndex 集合
|
||||
func protectedSpineIndices(
|
||||
currentSpineIndex: Int?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
@@ -212,7 +196,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
if let currentSpineIndex {
|
||||
indices.insert(currentSpineIndex)
|
||||
// 添加相邻章节
|
||||
|
||||
if currentSpineIndex > 0 {
|
||||
indices.insert(currentSpineIndex - 1)
|
||||
}
|
||||
|
||||
+37
-38
@@ -1,13 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器标注协调器:负责高亮、批注和书签的增删改查操作。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 管理高亮(highlight)与批注(annotation)的创建、更新和删除
|
||||
/// - 管理书签(bookmark)的添加、切换和删除
|
||||
/// - 处理文本选中后的菜单操作(复制、高亮、批注)
|
||||
/// - 弹出高亮管理器和书签管理器界面
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -18,19 +12,16 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 根据 ID 查找书签。
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeBookmarks.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 根据 ID 查找高亮。
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
if let selection, !selection.isEmpty {
|
||||
applySelectionState(.selected(selection))
|
||||
@@ -39,9 +30,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一选区状态变更入口。
|
||||
/// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。
|
||||
/// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。
|
||||
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||
guard let controller else { return }
|
||||
context.selectionState = state
|
||||
@@ -52,9 +40,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
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:
|
||||
@@ -62,7 +47,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -72,7 +56,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 基于选区创建标注(高亮/划线/批注),支持指定样式、颜色和备注。
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -115,7 +98,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newHighlight
|
||||
}
|
||||
|
||||
/// 插入或更新高亮(upsert),按 ID 匹配已有记录。
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -132,7 +114,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return scopedHighlight
|
||||
}
|
||||
|
||||
/// 根据 ID 删除高亮并持久化。
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -144,7 +125,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 更新指定高亮的批注备注内容。
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -156,7 +136,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.activeHighlights[index]
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮所在位置。
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
@@ -165,7 +144,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return navigate(to: highlight, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除所有高亮标记。
|
||||
func removeAllHighlights() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
@@ -173,7 +151,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
persistHighlightsAndRefreshContent()
|
||||
}
|
||||
|
||||
/// 将选区位置相对于指定 spine 索引进行规范化。
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@@ -190,7 +167,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: selection.location.progression,
|
||||
lastProgression: selection.location.lastProgression,
|
||||
fragment: selection.location.fragment,
|
||||
rangeAnchor: selection.location.rangeAnchor
|
||||
rangeAnchor: selection.location.rangeAnchor,
|
||||
cfi: selection.location.cfi,
|
||||
lastCFI: selection.location.lastCFI,
|
||||
rangeCFI: selection.location.rangeCFI
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
@@ -201,7 +181,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
/// 弹出高亮管理器,支持查看、编辑备注和删除高亮。
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
@@ -231,7 +210,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出标注创建面板(高亮/划线/批注选择)。
|
||||
func presentAnnotationCreation() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights,
|
||||
@@ -241,7 +219,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
/// 弹出已有高亮/批注的操作菜单。
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
||||
@@ -263,7 +240,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 处理文本选中后的菜单操作:复制、高亮、批注。
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
guard let selection else { return }
|
||||
switch action {
|
||||
@@ -277,7 +253,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签,自动去重。
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -299,7 +274,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态:已存在则移除,不存在则添加。
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -315,7 +289,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 根据 ID 删除书签并持久化。
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -327,7 +300,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置。
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@@ -337,7 +309,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||||
}
|
||||
|
||||
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
@@ -374,7 +345,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: highlight.location.progression,
|
||||
lastProgression: highlight.location.lastProgression,
|
||||
fragment: highlight.location.fragment,
|
||||
rangeAnchor: highlight.location.rangeAnchor
|
||||
rangeAnchor: highlight.location.rangeAnchor,
|
||||
cfi: highlight.location.cfi,
|
||||
lastCFI: highlight.location.lastCFI,
|
||||
rangeCFI: highlight.location.rangeCFI
|
||||
)
|
||||
return RDEPUBHighlight(
|
||||
id: highlight.id,
|
||||
@@ -407,7 +381,21 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
refreshVisibleContentPreservingCurrentPage()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentPreservingCurrentPage() {
|
||||
guard let controller else { return }
|
||||
let currentPage = controller.readerView.currentPage
|
||||
guard currentPage >= 0 else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
|
||||
controller.readerView.reloadData()
|
||||
if controller.readerView.currentPage != currentPage {
|
||||
controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||
@@ -511,6 +499,11 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return false
|
||||
}
|
||||
|
||||
if let bookmarkCFI = bookmark.location.cfi,
|
||||
let locationCFI = location.cfi {
|
||||
return bookmarkCFI == locationCFI
|
||||
}
|
||||
|
||||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||||
let locationAnchor = location.rangeAnchor {
|
||||
return bookmarkAnchor == locationAnchor
|
||||
@@ -545,7 +538,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
@@ -559,7 +555,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器界面组装协调器:负责阅读器初次启动时的 UI 搭建。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 组装阅读器视图层次结构(readerView、loadingIndicator、errorLabel)
|
||||
/// - 注册内容视图类型
|
||||
/// - 处理外部纯文本图书的启动收尾逻辑
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -13,7 +7,6 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 组装阅读器界面:添加 readerView、loadingIndicator、errorLabel 到控制器视图,并配置顶部工具栏。
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@@ -28,7 +21,6 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器 Chrome 协调器:负责顶部/底部工具栏的创建、更新和交互处理。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建并配置顶部工具栏(返回、书签按钮)
|
||||
/// - 创建并配置底部工具栏(目录、书签、高亮、设置按钮)
|
||||
/// - 同步工具栏的主题和状态
|
||||
/// - 弹出设置面板和目录面板
|
||||
/// - 处理返回按钮的关闭逻辑
|
||||
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -19,14 +12,11 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
context.controller
|
||||
}
|
||||
|
||||
// MARK: - UIAdaptivePresentationControllerDelegate
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
// 设置页面被用户下滑关闭时
|
||||
|
||||
context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
@@ -41,7 +31,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图,绑定目录、书签、高亮、设置等回调。
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
@@ -62,7 +51,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
let uiState = makeUIState()
|
||||
@@ -70,7 +58,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
updateSearchBar()
|
||||
}
|
||||
|
||||
/// 构建当前 UI 状态快照
|
||||
func makeUIState() -> RDEPUBReaderUIState {
|
||||
guard let controller else { return .empty }
|
||||
return RDEPUBReaderUIState(
|
||||
@@ -85,7 +72,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 UI 状态应用到顶部和底部工具栏
|
||||
func applyUIState(_ state: RDEPUBReaderUIState) {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
@@ -107,18 +93,15 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
|
||||
}
|
||||
|
||||
/// 判断当前位置是否有书签
|
||||
private func hasBookmarkAtCurrentLocation() -> Bool {
|
||||
guard let controller else { return false }
|
||||
return context.runtime?.annotationCoordinator.currentBookmark() != nil
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
|
||||
// 通知 Runtime 设置页面即将打开
|
||||
context.runtime?.settingsPanelWillAppear()
|
||||
|
||||
let settingsController = RDEPUBReaderSettingsViewController(
|
||||
@@ -147,7 +130,7 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller?.updateConfiguration { $0.theme = theme }
|
||||
}
|
||||
settingsController.onDismiss = { [weak self] in
|
||||
// 通知 Runtime 设置页面已关闭
|
||||
|
||||
self?.context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
@@ -157,7 +140,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出目录列表面板,支持点击跳转到指定章节。
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
@@ -184,7 +166,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 切换搜索栏的显示/隐藏状态。
|
||||
func toggleSearchBar() {
|
||||
guard let controller else { return }
|
||||
if controller.isSearchBarVisible {
|
||||
@@ -194,7 +175,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏的主题和匹配计数。
|
||||
func updateSearchBar() {
|
||||
guard let controller else { return }
|
||||
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||
@@ -207,7 +187,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
|
||||
@@ -1,65 +1,51 @@
|
||||
import UIKit
|
||||
|
||||
/// 阅读器共享状态中心:所有 coordinator 通过 context 访问业务状态和便捷方法。
|
||||
///
|
||||
/// context 持有:
|
||||
/// - 业务状态(parser、publication、textBook、pages 等)
|
||||
/// - UI 配置(configuration、brightness)
|
||||
/// - 持久化策略(persistence)
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
|
||||
private let activityLock = NSLock()
|
||||
|
||||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||||
|
||||
// MARK: - 引用
|
||||
|
||||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||||
weak var controller: RDEPUBReaderController?
|
||||
/// 弱引用阅读器视图,用于布局和页面状态查询。
|
||||
|
||||
weak var readerView: RDReaderView?
|
||||
/// 依赖注入容器,提供解析器、分页器等工厂方法。
|
||||
|
||||
var dependencies: RDEPUBReaderDependencies = .live
|
||||
/// 便捷访问当前控制器的运行时协调器集合。
|
||||
|
||||
var runtime: RDEPUBReaderRuntime? {
|
||||
controller?.runtime
|
||||
}
|
||||
|
||||
// MARK: - 业务状态
|
||||
|
||||
/// EPUB 解析器实例。
|
||||
var parser: RDEPUBParser?
|
||||
/// 解析后的出版物模型。
|
||||
|
||||
var publication: RDEPUBPublication?
|
||||
/// 当前阅读会话,管理页面和章节状态。
|
||||
|
||||
var readingSession: RDEPUBReadingSession?
|
||||
/// 原生文本排版生成的图书模型(仅文本重排模式)。
|
||||
/// 章节模式下为 nil,内容通过 ChapterRuntimeStore 访问。
|
||||
|
||||
var textBook: RDEPUBTextBook?
|
||||
/// 全书轻量页码映射(章节模式)。约 100KB/1000章,不持有 NSAttributedString。
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
/// 当前书籍的所有书签。
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
/// 当前书籍的所有高亮标注。
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
/// 当前打开书籍的唯一标识。
|
||||
|
||||
var currentBookIdentifier: String?
|
||||
/// 分页操作令牌,用于取消过期的异步分页任务。
|
||||
|
||||
var paginationToken = UUID()
|
||||
/// Web 内容分页计算器。
|
||||
|
||||
var paginator: RDEPUBPaginator?
|
||||
/// 全文搜索状态。
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
/// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。
|
||||
/// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
/// 后台元数据解析使用的并发数。
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
/// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
@@ -70,38 +56,30 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一选区状态模型,收口所有选区相关状态变更。
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
/// 阅读器配置(字号、字体、主题等)。
|
||||
var configuration: RDEPUBReaderConfiguration = .default
|
||||
/// 持久化策略,负责书签、高亮、阅读位置的存取。
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
/// 当前打开的 EPUB 文件 URL。
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
/// 是否正在重新分页。
|
||||
var isRepaginating: Bool = false
|
||||
/// 是否已完成首次加载。
|
||||
var didStartInitialLoad: Bool = false
|
||||
/// 是否为外部传入的纯文本图书。
|
||||
var isExternalTextBook: Bool = false
|
||||
/// 外部纯文本文件的 URL。
|
||||
var textFileURL: URL?
|
||||
/// 文本图书缓存,避免重复排版。
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
// MARK: - 初始化
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
|
||||
var didStartInitialLoad: Bool = false
|
||||
|
||||
var isExternalTextBook: Bool = false
|
||||
|
||||
var textFileURL: URL?
|
||||
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
init(controller: RDEPUBReaderController) {
|
||||
self.controller = controller
|
||||
self.readerView = controller.readerView
|
||||
}
|
||||
|
||||
// MARK: - 便捷方法
|
||||
|
||||
/// 根据当前 readerView 和控制器尺寸构建布局上下文。
|
||||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||||
let containerSize = readerView?.bounds.size ?? .zero
|
||||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||||
@@ -115,12 +93,10 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据当前配置生成阅读偏好设置。
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
}
|
||||
|
||||
/// 获取当前文本排版的单页尺寸,优先从 readerView 解析,兜底用布局上下文。
|
||||
func currentTextPageSize() -> CGSize {
|
||||
if Thread.isMainThread {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
@@ -149,7 +125,6 @@ final class RDEPUBReaderContext {
|
||||
return dependencies.environment.fallbackViewportSize
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式(字体、行距、颜色)。
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
@@ -161,7 +136,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面尺寸和配置生成文本排版参数。
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
@@ -169,7 +143,7 @@ final class RDEPUBReaderContext {
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
// 小说正文更看重尽量铺满页面,避免页尾出现明显留白。
|
||||
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
@@ -179,48 +153,39 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前配置对应的文本渲染器实例。
|
||||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||||
}
|
||||
|
||||
/// 当前活跃的页面列表。
|
||||
var activePages: [EPUBPage] {
|
||||
readingSession?.activePages ?? []
|
||||
}
|
||||
|
||||
/// 当前活跃的章节信息列表。
|
||||
var activeChapters: [EPUBChapterInfo] {
|
||||
readingSession?.activeChapters ?? []
|
||||
}
|
||||
|
||||
/// 屏幕亮度代理属性,读写均转发给系统环境。
|
||||
var currentBrightness: CGFloat {
|
||||
get { dependencies.environment.currentBrightness }
|
||||
set { dependencies.environment.currentBrightness = newValue }
|
||||
}
|
||||
|
||||
/// 用新快照替换当前活跃的分页快照。
|
||||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||||
readingSession?.setActiveSnapshot(snapshot)
|
||||
}
|
||||
|
||||
/// 清除当前活跃快照,重置运行时状态。
|
||||
func clearActiveSnapshot() {
|
||||
readingSession?.resetRuntimeState()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 EPUB 解析器。
|
||||
func makeParser() -> RDEPUBParser {
|
||||
dependencies.makeParser()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 Web 内容分页计算器。
|
||||
func makePaginator() -> RDEPUBPaginator {
|
||||
dependencies.makePaginator()
|
||||
}
|
||||
|
||||
/// 工厂方法:创建 EPUB 文本图书构建器。
|
||||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||||
}
|
||||
@@ -252,8 +217,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。
|
||||
/// 后台批量解析必须走此版本。
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||
chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
@@ -262,8 +225,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用固定的渲染签名与预计算 contentHash 构建缓存键。
|
||||
/// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。
|
||||
func chapterCacheKey(
|
||||
forSpineIndex spineIndex: Int,
|
||||
precomputedContentHash: String,
|
||||
@@ -277,7 +238,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前渲染参数签名,所有章节共享同一值。
|
||||
func currentRenderSignature() -> String {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
@@ -311,36 +271,30 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 工厂方法:创建纯文本图书构建器。
|
||||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置。
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
controller?.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 从持久化存储加载上次保存的阅读位置。
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let currentBookIdentifier else { return nil }
|
||||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 将阅读位置持久化到存储。
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let currentBookIdentifier else { return }
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 记录最近一次用户翻页/跳转行为,用于后台分页让路。
|
||||
func markUserNavigationActivity() {
|
||||
activityLock.lock()
|
||||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||||
activityLock.unlock()
|
||||
}
|
||||
|
||||
/// 距离最近一次用户翻页/跳转已经过去的时间。
|
||||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||||
activityLock.lock()
|
||||
let timestamp = lastUserNavigationTimestamp
|
||||
@@ -349,7 +303,6 @@ final class RDEPUBReaderContext {
|
||||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据。
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||||
@@ -358,42 +311,34 @@ final class RDEPUBReaderContext {
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
/// 显示加载指示器。
|
||||
func showLoading() {
|
||||
controller?.showLoading()
|
||||
}
|
||||
|
||||
/// 隐藏加载指示器。
|
||||
func hideLoading() {
|
||||
controller?.hideLoading()
|
||||
}
|
||||
|
||||
/// 将错误转发给控制器处理。
|
||||
func handle(error: Error) {
|
||||
controller?.handle(error: error)
|
||||
}
|
||||
|
||||
/// 触发工具栏状态更新。
|
||||
func updateReaderChrome() {
|
||||
controller?.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保持当前阅读位置不变。
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
controller?.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置。
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置。
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
controller?.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
|
||||
/// 将当前配置应用到阅读器视图。
|
||||
func applyReaderViewConfiguration() {
|
||||
controller?.applyReaderViewConfiguration()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// RDEPUBReaderDependencies.swift
|
||||
// EPUB 阅读器依赖注入配置,定义显示环境协议与核心组件工厂
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 阅读器显示环境协议,抽象屏幕亮度和视口尺寸以支持测试和多平台适配
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
/// 当前屏幕亮度,取值范围 0.0 ~ 1.0
|
||||
|
||||
var currentBrightness: CGFloat { get set }
|
||||
/// 后备视口尺寸,用于无法获取实际视图尺寸时的布局计算
|
||||
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
/// 基于 UIScreen 的默认显示环境实现,直接读写系统屏幕亮度
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
@@ -25,29 +21,20 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 阅读器核心依赖容器,通过工厂闭包注入各组件以便替换和测试
|
||||
public struct RDEPUBReaderDependencies {
|
||||
/// 显示环境实例
|
||||
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
/// EPUB 解析器工厂
|
||||
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
/// 分页器工厂
|
||||
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
/// 富文本书籍构建器工厂
|
||||
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
/// 纯文本书籍构建器工厂
|
||||
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
/// 文本渲染器工厂
|
||||
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
/// 初始化依赖容器
|
||||
/// - Parameters:
|
||||
/// - environment: 显示环境实例
|
||||
/// - makeParser: EPUB 解析器工厂闭包
|
||||
/// - makePaginator: 分页器工厂闭包
|
||||
/// - makeTextBookBuilder: 富文本书籍构建器工厂闭包
|
||||
/// - makePlainTextBookBuilder: 纯文本书籍构建器工厂闭包
|
||||
/// - makeTextRenderer: 文本渲染器工厂闭包
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
@@ -64,7 +51,6 @@ public struct RDEPUBReaderDependencies {
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
}
|
||||
|
||||
/// 默认生产环境依赖,使用系统屏幕环境和标准组件实现
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器加载协调器:负责 EPUB 文件的解析和出版物初始化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 判断是否需要执行首次加载
|
||||
/// - 后台解析 EPUB 文件并构建 Publication 模型
|
||||
/// - 将解析结果应用到阅读器上下文并触发分页
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -13,7 +7,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 检查条件后启动首次加载,确保只执行一次且视图已布局。
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
@@ -26,7 +19,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
/// 后台解析 EPUB 文件,加载书签、高亮和阅读位置,完成后回调主线程。
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
@@ -65,7 +57,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将解析完成的出版物应用到上下文,设置书签/高亮/会话,并触发分页。
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器位置协调器:负责阅读位置的恢复、查询和持久化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据保存的位置恢复阅读进度
|
||||
/// - 获取当前可见页面的阅读位置
|
||||
/// - 从持久化存储加载已保存位置
|
||||
/// - 持久化当前位置并通知委托
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 上次翻页时的 spineIndex,用于检测跨章翻页
|
||||
private var lastPageChangeSpineIndex: Int?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -57,13 +48,11 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
|
||||
// 记录翻页到 JumpSession
|
||||
recordPageChangeIfNeeded()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的阅读位置。
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
@@ -74,8 +63,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
|
||||
return location
|
||||
}
|
||||
// resolvedTextLocation 可能因章节数据未加载而返回 nil,
|
||||
// 通过 readingSession 的 activePages 构建回退位置
|
||||
|
||||
if let readingSession = context.readingSession,
|
||||
readingSession.activePages.indices.contains(readerView.currentPage) {
|
||||
return readingSession.fallbackLocation(
|
||||
@@ -87,7 +75,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 从持久化存储加载上次保存的阅读位置。
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
@@ -96,7 +83,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 持久化阅读位置,并通知委托更新目录项和书签状态。
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
@@ -106,7 +92,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
controller.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 记录翻页到 JumpSession
|
||||
func recordPageChangeIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let bookPageMap = context.bookPageMap,
|
||||
@@ -127,7 +112,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
|
||||
lastPageChangeSpineIndex = currentSpineIndex
|
||||
|
||||
// 检查是否应该结束 JumpSession
|
||||
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
|
||||
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
@@ -137,7 +121,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置翻页状态(用于重新加载等场景)
|
||||
func resetPageChangeState() {
|
||||
lastPageChangeSpineIndex = nil
|
||||
}
|
||||
|
||||
+33
-111
@@ -1,17 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
private final class MetadataParseState {
|
||||
|
||||
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
|
||||
|
||||
var totalResolvedCount: Int
|
||||
|
||||
var lastAppliedCount: Int
|
||||
|
||||
init(
|
||||
@@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private final class MetadataParseCancellationController {
|
||||
|
||||
let token: UUID
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private weak var queue: OperationQueue?
|
||||
|
||||
private var cancelled = false
|
||||
|
||||
init(token: UUID) {
|
||||
@@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
/// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。
|
||||
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let metadataParseControlLock = NSLock()
|
||||
|
||||
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
@@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
@@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保持当前阅读位置不变。
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
@@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重建外部纯文本图书(布局变更后重新排版)。
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
@@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
guard context.controller != nil else { return }
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
@@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||||
) {
|
||||
try self.loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||||
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
runtime.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
@@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
private func loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> [RDEPUBRuntimeChapter] {
|
||||
let windowSpineIndices = initialWindowSpineIndices(
|
||||
around: anchorSpineIndex,
|
||||
in: publication,
|
||||
maxChapterCount: context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in windowSpineIndices {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == anchorSpineIndex {
|
||||
throw error
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func initialWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
) -> [Int] {
|
||||
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
|
||||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
while selected.count < normalizedMaxChapterCount,
|
||||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||||
if nextPosition < buildableIndices.count {
|
||||
selected.append(buildableIndices[nextPosition])
|
||||
nextPosition += 1
|
||||
if selected.count == normalizedMaxChapterCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if previousPosition >= 0 {
|
||||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||||
previousPosition -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
@@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
// MARK: - 元数据专用解析(Phase 0)
|
||||
|
||||
/// 失败重试配置
|
||||
private static let maxRetryCount = 3
|
||||
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
||||
|
||||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||||
/// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。
|
||||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
guard let parser = context.parser,
|
||||
@@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
|
||||
// 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
@@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优先级排序获取未缓存的 spineIndex
|
||||
let prioritizedSpineIndices: [Int]
|
||||
if let priorityManager = context.runtime?.backgroundPriorityManager {
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
@@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
@@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// 锁内只做写入和计数,快照数据后锁外构建 pageMap
|
||||
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||
resultLock.lock()
|
||||
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
||||
@@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
timingLock.unlock()
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
|
||||
// 添加到重试队列(带退避延迟)
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: 0,
|
||||
@@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
// 存储到 BackgroundCoverageStore
|
||||
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
||||
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
||||
let lowerSpine = resolvedSpineIndices.min() ?? 0
|
||||
@@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度失败章节的重试
|
||||
private func scheduleRetry(
|
||||
spineIndex: Int,
|
||||
retryCount: Int,
|
||||
@@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
@@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
"MetadataParse",
|
||||
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
|
||||
)
|
||||
// 继续重试
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: retryCount + 1,
|
||||
|
||||
@@ -1,65 +1,78 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器运行时总协调器。
|
||||
/// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器,
|
||||
/// 作为阅读器控制器的门面(Facade),简化外部调用。
|
||||
final class RDEPUBReaderRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
|
||||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||||
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
return loader
|
||||
}()
|
||||
|
||||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
|
||||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||||
|
||||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||||
|
||||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||||
|
||||
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
|
||||
|
||||
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
|
||||
|
||||
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
|
||||
|
||||
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
|
||||
|
||||
/// 设置页面是否打开
|
||||
var isSettingsPanelOpen: Bool = false
|
||||
|
||||
/// 标记需要在设置页面关闭后触发完整补全
|
||||
var needsFullRepaginationAfterSettingsClose: Bool = false
|
||||
|
||||
/// 设置页内当前章预览的代次,用于丢弃过期结果
|
||||
private var settingsPreviewGeneration: Int = 0
|
||||
/// 设置页内 preview 防抖任务,只保留最后一次请求。
|
||||
|
||||
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
||||
/// 设置页内 preview 防抖延迟,合并连续点击。
|
||||
|
||||
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
||||
|
||||
private struct SettingsPreviewAnchor {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let offset: Int
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
/// 若尚未加载则启动首次加载流程
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 重新加载当前书籍,清空解析器、分页、批注等状态后从头初始化
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
@@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime {
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 目标位置
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
@@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清除当前选区
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
@@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
/// 按关键词搜索全文
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
/// 跳转到指定搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
searchCoordinator.selectSearchMatch(at: index)
|
||||
}
|
||||
|
||||
/// 清除搜索状态
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
@@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
/// 更新阅读器工具栏显示状态
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
/// 弹出目录面板
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
/// 启动 Publication 加载流程
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
/// 将已解析的 Publication 应用到控制器
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对 Publication 执行分页计算
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用外部 TextBook 并恢复阅读位置
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照并恢复阅读位置
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
// 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转
|
||||
// 此时保留旧 map,用户看到的内容和页码完全不变
|
||||
if let pendingMap = context.pendingFullPageMap {
|
||||
let shouldKeepExisting =
|
||||
pendingMap.totalChapters > bookPageMap.totalChapters ||
|
||||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
|
||||
pendingMap.totalPages >= bookPageMap.totalPages)
|
||||
if shouldKeepExisting {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
}
|
||||
|
||||
/// 用户导航时检查并应用待处理的完整 BookPageMap
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
// 如果正在重新分页,跳过本次检查,避免状态冲突
|
||||
guard !controller.isRepaginating else { return }
|
||||
|
||||
// 使用协调器判断是否允许接管
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: pendingMap,
|
||||
candidateSegment: nil,
|
||||
@@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime {
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
// 这些情况在当前实现中不会发生,因为我们传入的是 candidatePageMap
|
||||
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用全量页图替换
|
||||
private func applyFullPageMapReplacement(
|
||||
_ newPageMap: RDEPUBBookPageMap,
|
||||
readerView: RDReaderView,
|
||||
@@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
context.pendingFullPageMap = nil
|
||||
|
||||
// 替换 map 和快照
|
||||
context.textBook = nil
|
||||
context.bookPageMap = newPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
|
||||
|
||||
// 用位置在新 map 中重新解析正确的页码
|
||||
if let currentLocation {
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
@@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime {
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
// 检查是否应该结束 JumpSession(coverage-complete)
|
||||
if let currentLocation,
|
||||
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
|
||||
let activeSession = jumpSessionManager.activeSession {
|
||||
@@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置不变
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
// 如果设置页面打开,只计算当前章节(热区)
|
||||
|
||||
if isSettingsPanelOpen {
|
||||
needsFullRepaginationAfterSettingsClose = true
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
@@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 防抖调度设置页内的当前章 preview,只保留最后一次请求。
|
||||
private func scheduleSettingsPreviewRepagination() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
settingsPreviewGeneration += 1
|
||||
@@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime {
|
||||
return
|
||||
}
|
||||
self.pendingSettingsPreviewWorkItem = nil
|
||||
let previewAnchor = self.captureSettingsPreviewAnchor()
|
||||
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration)
|
||||
self.repaginateCurrentChapterOnly(
|
||||
previewGeneration: previewGeneration,
|
||||
previewAnchor: previewAnchor
|
||||
)
|
||||
}
|
||||
pendingSettingsPreviewWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(
|
||||
@@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 只重新计算当前章节(设置页面打开时使用)
|
||||
private func repaginateCurrentChapterOnly(previewGeneration: Int) {
|
||||
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return nil }
|
||||
|
||||
let absolutePageIndex = readerView.currentPage
|
||||
guard absolutePageIndex >= 0,
|
||||
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
chapter.pages.indices.contains(localPageIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let page = chapter.pages[localPageIndex]
|
||||
let offset = page.contentRange.length > 0
|
||||
? page.contentRange.location
|
||||
: page.pageStartOffset
|
||||
return SettingsPreviewAnchor(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
offset: offset
|
||||
)
|
||||
}
|
||||
|
||||
private func repaginateCurrentChapterOnly(
|
||||
previewGeneration: Int,
|
||||
previewAnchor: SettingsPreviewAnchor?
|
||||
) {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
@@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime {
|
||||
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
|
||||
readerView.reloadData()
|
||||
|
||||
if let targetPage = self.settingsPreviewTargetPage(
|
||||
in: chapter,
|
||||
for: previewAnchor
|
||||
) {
|
||||
readerView.transitionToPage(pageNum: targetPage, animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if let previewLocation,
|
||||
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
|
||||
return
|
||||
}
|
||||
|
||||
readerView.transitionToPage(pageNum: 0, animated: false)
|
||||
|
||||
case .failure(let error):
|
||||
@@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页面即将打开
|
||||
private func settingsPreviewTargetPage(
|
||||
in chapter: RDEPUBRuntimeChapter,
|
||||
for anchor: SettingsPreviewAnchor?
|
||||
) -> Int? {
|
||||
guard let anchor,
|
||||
anchor.spineIndex == chapter.spineIndex,
|
||||
anchor.href == chapter.href,
|
||||
!chapter.pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let exactPage = chapter.pages.first(where: { page in
|
||||
let lowerBound = page.contentRange.location
|
||||
let upperBound = page.contentRange.location + page.contentRange.length
|
||||
if page.contentRange.length == 0 {
|
||||
return anchor.offset == lowerBound
|
||||
}
|
||||
return anchor.offset >= lowerBound && anchor.offset < upperBound
|
||||
}) {
|
||||
return exactPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
if let nextPage = chapter.pages.first(where: { page in
|
||||
page.contentRange.location > anchor.offset
|
||||
}) {
|
||||
return nextPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
return max(chapter.pages.count - 1, 0)
|
||||
}
|
||||
|
||||
func settingsPanelWillAppear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
@@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime {
|
||||
settingsPreviewGeneration += 1
|
||||
}
|
||||
|
||||
/// 设置页面已关闭
|
||||
func settingsPanelDidDisappear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
isSettingsPanelOpen = false
|
||||
settingsPreviewGeneration += 1
|
||||
// 如果在设置页面期间有配置变化,触发完整补全
|
||||
|
||||
if needsFullRepaginationAfterSettingsClose {
|
||||
needsFullRepaginationAfterSettingsClose = false
|
||||
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
|
||||
@@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新当前可见内容,保持阅读位置不变
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部 TextBook 数据
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 获取当前视口签名快照
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
/// 视口变化时检查是否需要重新分页
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
@@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否是远距跳转
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
@@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
// 如果是远距跳转且目标已在当前窗口中,创建 JumpSession
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
@@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime {
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
// 远距跳转成功后创建 JumpSession
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
// 添加温区锚点
|
||||
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
@@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定当前阅读方向,优先向当前方向扩展
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
// 根据阅读方向决定扩展策略
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
// 向后扩展
|
||||
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
// 向前扩展
|
||||
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
// 不在边界,不扩展
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime {
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
|
||||
chapterRuntimeStore.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"initial open prefetch forward spine=\(spineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
@@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime {
|
||||
backgroundCoverageStore.clearAll()
|
||||
}
|
||||
|
||||
/// 处理内存警告
|
||||
func handleMemoryWarning() {
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
@@ -893,6 +968,76 @@ final class RDEPUBReaderRuntime {
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else {
|
||||
break
|
||||
}
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// 搜索协调器,负责管理全文搜索的执行、结果导航和搜索状态通知。
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -12,8 +12,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 按关键词执行全文搜索,匹配结果自动导航到首个命中位置
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -37,21 +35,16 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
/// 跳转到指定索引的搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@@ -66,7 +59,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
/// 清除搜索状态并刷新当前可见内容
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
@@ -74,9 +66,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 构建指定页面的搜索结果展示信息,用于高亮渲染
|
||||
/// - Parameter page: 目标页面
|
||||
/// - Returns: 搜索展示数据,若无搜索状态则返回 nil
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
@@ -175,6 +164,7 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
@@ -183,7 +173,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
@@ -210,6 +202,7 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
@@ -266,7 +259,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
return controller.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
@@ -298,7 +293,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// 阅读器 UI 状态模型:统一管理顶部/底部工具栏按钮的可用性和显示状态。
|
||||
///
|
||||
/// 解决问题:之前书签、高亮等按钮的状态分散在 ChromeCoordinator 和 AnnotationCoordinator 中,
|
||||
/// 导致状态更新入口不一致,容易出现不同步的情况。
|
||||
struct RDEPUBReaderUIState {
|
||||
/// 顶部书签按钮是否可用(当前有书籍标识时可用)
|
||||
|
||||
let canToggleBookmark: Bool
|
||||
/// 顶部书签按钮是否选中(当前位置已加书签时选中)
|
||||
|
||||
let hasBookmarkAtCurrentLocation: Bool
|
||||
/// 底部书签列表按钮是否可用(有书签数据时可用)
|
||||
|
||||
let canShowBookmarks: Bool
|
||||
/// 底部新建标注按钮是否可用(有选中文本时可用)
|
||||
|
||||
let canAddHighlight: Bool
|
||||
/// 底部高亮列表按钮是否可用(有高亮数据时可用)
|
||||
|
||||
let canShowHighlights: Bool
|
||||
/// 是否显示目录按钮
|
||||
|
||||
let showsTableOfContents: Bool
|
||||
/// 是否显示高亮相关按钮(新建标注和高亮列表)
|
||||
|
||||
let allowsHighlights: Bool
|
||||
/// 是否显示设置按钮
|
||||
|
||||
let showsSettingsPanel: Bool
|
||||
}
|
||||
|
||||
extension RDEPUBReaderUIState {
|
||||
/// 默认的空状态
|
||||
|
||||
static let empty = RDEPUBReaderUIState(
|
||||
canToggleBookmark: false,
|
||||
hasBookmarkAtCurrentLocation: false,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口监测器,监听视图布局和屏幕旋转等视口变化事件,
|
||||
/// 检测变化是否显著,必要时触发重新分页或内容重建。
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -18,7 +20,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 视图布局完成后检查视口是否发生变化,首次布局时触发初始加载
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
@@ -41,8 +42,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
/// 屏幕旋转前捕获当前阅读位置,旋转完成后检测视口变化并处理
|
||||
/// - Parameter coordinator: 转场协调器
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
@@ -57,7 +56,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有视口状态,用于重新加载书籍
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
@@ -65,19 +63,16 @@ final class RDEPUBReaderViewportMonitor {
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
/// 消费并返回待恢复的阅读位置(一次性读取后清空)
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
/// 主动捕获当前阅读位置到待恢复队列,供后续视口变化后恢复使用
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
/// 分页完成后处理挂起的视口变化(如有),避免分页过程中重复触发
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
@@ -86,7 +81,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前视口签名,包含容器尺寸和安全区域信息
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
@@ -102,7 +96,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检测视口签名是否发生显著变化,若变化则触发重新分页或重建外部 TextBook
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
// 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:
|
||||
@@ -27,7 +20,6 @@ enum RDEPUBSelectionState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前选区数据(如有)
|
||||
var selection: RDEPUBSelection? {
|
||||
switch self {
|
||||
case .idle, .selecting:
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 文本渲染引擎
|
||||
|
||||
/// EPUB 文本渲染引擎枚举
|
||||
/// 定义了当前支持的文本渲染方式,未来可扩展为多引擎选择
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
/// 基于 DTCoreText 的渲染引擎,将 HTML/CSS 转换为 NSAttributedString
|
||||
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
// MARK: - 阅读字体
|
||||
|
||||
/// 阅读器内置字体选项。
|
||||
/// 第一版优先使用系统授权字体,后续可扩展为 bundle 内置字体注册。
|
||||
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
|
||||
/// 系统默认无衬线字体
|
||||
|
||||
case system
|
||||
/// 系统衬线字体
|
||||
|
||||
case serif
|
||||
/// 系统圆体字体
|
||||
|
||||
case rounded
|
||||
/// 系统等宽字体
|
||||
|
||||
case monospaced
|
||||
|
||||
public var displayName: String {
|
||||
@@ -54,108 +46,58 @@ public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 阅读器配置
|
||||
|
||||
/// EPUB 阅读器的完整配置结构体
|
||||
/// 作为 EPUBUI 层的入口配置,控制阅读器的外观、行为和功能开关
|
||||
/// 调用方在创建 RDEPUBReaderController 前可自定义此配置
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
// MARK: 阅读排版参数
|
||||
|
||||
/// 正文字号(单位:pt),默认 15
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
|
||||
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 正文字体,默认系统字体
|
||||
|
||||
public var fontChoice: RDEPUBReaderFontChoice
|
||||
/// 栏数,默认单栏
|
||||
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,默认 20pt
|
||||
|
||||
public var columnGap: CGFloat
|
||||
/// 翻页显示模式:.pageCurl(仿真翻页)或 .scroll(滚动阅读)
|
||||
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
/// 横屏时是否启用双页显示,默认开启
|
||||
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
|
||||
// MARK: UI 功能开关
|
||||
|
||||
/// 是否显示目录入口,默认开启
|
||||
public var showsTableOfContents: Bool
|
||||
/// 是否允许文本高亮功能,默认开启
|
||||
|
||||
public var allowsHighlights: Bool
|
||||
/// 是否显示设置面板入口,默认开启
|
||||
|
||||
public var showsSettingsPanel: Bool
|
||||
|
||||
// MARK: 内容内边距
|
||||
|
||||
/// 流式排版(reflowable)内容的内边距,上下 40pt、左右 16pt
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
/// 固定布局(fixed layout)内容的内边距,默认为零
|
||||
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
|
||||
// MARK: 主题与布局
|
||||
|
||||
/// 当前阅读主题(浅色/深色/护眼等),默认 .light
|
||||
public var theme: RDEPUBReaderTheme
|
||||
/// 暗色主题下是否柔化正文图片,降低夜间阅读刺眼感
|
||||
|
||||
public var darkImageAdjustmentEnabled: Bool
|
||||
/// 暗色主题图片柔化混合比例,0 表示不处理,推荐 0.12 ~ 0.2
|
||||
|
||||
public var darkImageBlendRatio: CGFloat
|
||||
/// 固定布局的适配模式:按页适配或按宽度适配
|
||||
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定布局的跨页模式:自动/单页/双页
|
||||
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 文本渲染引擎,默认使用 DTCoreText
|
||||
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
/// 章节按需加载窗口大小(总章节数,包含当前章),默认 3,范围 3...15,偶数自动向上取奇
|
||||
|
||||
public var onDemandChapterWindowSize: Int
|
||||
/// 后台元数据解析并发数,默认为 CPU 核心数。
|
||||
/// 若 profiling 显示 renderTotalMs ≈ wallClockMs(渲染受限),维持核心数即可;
|
||||
/// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。
|
||||
|
||||
public var metadataParsingConcurrency: Int
|
||||
|
||||
// MARK: JumpSession 策略
|
||||
|
||||
/// JumpSession 策略配置
|
||||
public var jumpSessionPolicy: RDEPUBJumpSessionPolicy
|
||||
|
||||
// MARK: 安全策略
|
||||
|
||||
/// 允许直接打开的外部 URL scheme 集合,默认仅允许 https
|
||||
public var allowedExternalURLSchemes: Set<String>
|
||||
/// 打开外部链接前是否需要用户确认,默认 true
|
||||
|
||||
public var requiresExternalLinkConfirmation: Bool
|
||||
/// 是否允许正文和离屏分页 WebView 开启 inspectable,默认 false
|
||||
|
||||
public var allowsInspectableWebViews: Bool
|
||||
/// 是否允许输出完整 WebView 消息体日志,默认 false
|
||||
|
||||
public var enablesVerboseWebViewLogging: Bool
|
||||
|
||||
// MARK: 初始化
|
||||
|
||||
/// 创建阅读器配置,所有参数均提供合理的默认值
|
||||
/// - Parameters:
|
||||
/// - fontSize: 正文字号,默认 15pt
|
||||
/// - lineHeightMultiple: 行距倍数,默认 1.6
|
||||
/// - fontChoice: 正文字体,默认系统字体
|
||||
/// - displayType: 翻页模式,默认 .pageCurl
|
||||
/// - landscapeDualPageEnabled: 横屏双页,默认 true
|
||||
/// - showsTableOfContents: 显示目录入口,默认 true
|
||||
/// - allowsHighlights: 允许高亮,默认 true
|
||||
/// - showsSettingsPanel: 显示设置面板,默认 true
|
||||
/// - reflowableContentInsets: 流式排版内边距
|
||||
/// - fixedContentInset: 固定布局内边距
|
||||
/// - theme: 阅读主题,默认 .light
|
||||
/// - darkImageAdjustmentEnabled: 暗色主题下是否柔化正文图片
|
||||
/// - darkImageBlendRatio: 暗色主题图片柔化混合比例
|
||||
/// - fixedLayoutFit: 固定布局适配模式
|
||||
/// - fixedLayoutSpreadMode: 固定布局跨页模式
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
||||
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
||||
/// - allowedExternalURLSchemes: 允许直接打开的外部 URL scheme 集合,默认仅 https
|
||||
/// - requiresExternalLinkConfirmation: 打开外部链接前是否需要确认,默认 true
|
||||
/// - allowsInspectableWebViews: 是否允许开启 inspectable,默认 false
|
||||
/// - enablesVerboseWebViewLogging: 是否允许输出完整 WebView 消息体日志,默认 false
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@@ -210,11 +152,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||
}
|
||||
|
||||
/// 默认配置实例,使用所有参数的默认值
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
|
||||
static func normalizedChapterWindowSize(_ size: Int) -> Int {
|
||||
let clamped = max(3, min(15, size))
|
||||
return clamped % 2 == 0 ? clamped + 1 : clamped
|
||||
@@ -225,11 +167,8 @@ extension RDEPUBReaderConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 配置转换
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
/// 将用户可见的配置转换为底层排版引擎所需的 RDEPUBPreferences
|
||||
/// - Returns: 排版偏好设置实例,供 Core 层的渲染管道使用
|
||||
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 翻页显示模式
|
||||
|
||||
/// 阅读器翻页显示模式枚举
|
||||
/// 用于持久化存储用户的翻页偏好设置,与 RDReaderView.DisplayType 相互转换
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
/// 仿真翻页模式(纸张翻转效果)
|
||||
|
||||
case pageCurl
|
||||
/// 水平滚动模式
|
||||
|
||||
case horizontalScroll
|
||||
/// 垂直滚动模式
|
||||
|
||||
case verticalScroll
|
||||
/// 水平覆盖式滚动模式(类似电子杂志翻页效果)
|
||||
|
||||
case horizontalCoverScroll
|
||||
|
||||
/// 从底层 DisplayType 转换为 UI 层的 DisplayMode
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
@@ -26,8 +21,6 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为底层 RDReaderView 所需的 DisplayType
|
||||
/// 注意:horizontalCoverScroll 会被映射为 horizontalScroll
|
||||
var displayType: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
@@ -40,25 +33,20 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主题预设
|
||||
|
||||
/// 阅读器主题预设枚举
|
||||
/// 将 RDEPUBReaderTheme 映射为可序列化的枚举值,用于持久化存储和设置面板展示
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
/// 浅色主题
|
||||
|
||||
case light
|
||||
/// 黄色护眼主题
|
||||
|
||||
case yellow
|
||||
/// 绿色护眼主题
|
||||
|
||||
case green
|
||||
/// 粉色主题
|
||||
|
||||
case pink
|
||||
/// 蓝色主题
|
||||
|
||||
case blue
|
||||
/// 深色/夜间主题
|
||||
|
||||
case dark
|
||||
|
||||
/// 将预设枚举转换为主题配置实例
|
||||
public var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light:
|
||||
@@ -76,8 +64,6 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从主题配置实例反向查找对应的预设枚举
|
||||
/// 自定义主题(非内置预设)会返回 nil
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
@@ -98,30 +84,22 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 阅读器用户设置
|
||||
|
||||
/// 阅读器的可持久化用户设置
|
||||
/// 用于保存用户在设置面板中调整的阅读偏好(字号、行距、翻页模式、主题等)
|
||||
/// 所有属性均为可选类型,只覆盖用户实际修改过的配置项
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
// MARK: 可调参数
|
||||
|
||||
/// 屏幕亮度(0.0 ~ 1.0),nil 表示使用系统亮度
|
||||
public var brightness: CGFloat?
|
||||
/// 用户选择的正文字号(pt),nil 表示使用默认值
|
||||
|
||||
public var fontSize: CGFloat?
|
||||
/// 用户选择的正文字体,nil 表示使用默认值
|
||||
|
||||
public var fontChoice: RDEPUBReaderFontChoice?
|
||||
/// 用户选择的行距倍数,nil 表示使用默认值
|
||||
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
/// 用户选择的栏数,nil 表示使用默认值
|
||||
|
||||
public var numberOfColumns: Int?
|
||||
/// 用户选择的翻页模式,nil 表示使用默认值
|
||||
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
/// 用户选择的主题预设,nil 表示使用默认值
|
||||
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
/// 初始化用户设置,所有参数默认为 nil
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
@@ -140,10 +118,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
/// 将用户设置覆盖到基础配置上,得到最终生效的阅读器配置
|
||||
/// nil 属性不会覆盖基础配置的对应值
|
||||
/// - Parameter configuration: 基础配置(通常来自 RDEPUBReaderConfiguration.default)
|
||||
/// - Returns: 合并后的完整配置
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
@@ -169,12 +143,6 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
/// 从当前配置和亮度值捕获一份完整的用户设置快照
|
||||
/// 亮度值会被限制在 0.0 ~ 1.0 范围内
|
||||
/// - Parameters:
|
||||
/// - configuration: 当前生效的阅读器配置
|
||||
/// - brightness: 当前屏幕亮度
|
||||
/// - Returns: 完整的用户设置实例
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 设置面板
|
||||
|
||||
/// EPUB 阅读器的设置面板控制器
|
||||
/// 提供亮度、字号、行距、翻页方式、主题的调节功能
|
||||
/// 支持 iOS 13+ SF Symbols 和低版本文字回退
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 亮度变化回调(0.0 ~ 1.0)
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
/// 字号变化回调(pt 值)
|
||||
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
/// 字体变化回调
|
||||
|
||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||
/// 行距倍数变化回调
|
||||
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
/// 栏数变化回调
|
||||
|
||||
var onColumnCountChange: ((Int) -> Void)?
|
||||
/// 翻页模式变化回调
|
||||
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
/// 主题变化回调
|
||||
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
/// 设置页面关闭回调
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
// MARK: 主题预设枚举
|
||||
|
||||
/// 设置面板中的主题预设枚举,用于按钮显示和主题切换
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
@@ -60,20 +50,30 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
}
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
|
||||
private let contentStack: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private let brightnessSlider = UISlider()
|
||||
|
||||
private let fontValueLabel = UILabel()
|
||||
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
|
||||
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
|
||||
|
||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
||||
|
||||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
||||
|
||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||||
|
||||
private let themeStackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
@@ -81,11 +81,11 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
stackView.spacing = 12
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private var themeButtons: [UIButton] = []
|
||||
|
||||
/// 行距选项对应的倍数值(紧凑 1.3、标准 1.6、宽松 1.9)
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
/// 当前配置的本地副本,修改后通过回调通知控制器
|
||||
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 阅读器主题
|
||||
|
||||
/// EPUB 阅读器主题配置结构体
|
||||
/// 定义阅读器内容区域和工具栏的颜色方案
|
||||
/// 内置浅色、深色、黄色、绿色、粉色、蓝色六套预设主题
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
// MARK: 内容区颜色
|
||||
|
||||
/// 内容区域的背景色
|
||||
public var contentBackgroundColor: UIColor
|
||||
/// 内容区域的正文文字颜色
|
||||
|
||||
public var contentTextColor: UIColor
|
||||
|
||||
// MARK: 工具栏颜色
|
||||
|
||||
/// 工具栏背景色
|
||||
public var toolBackgroundColor: UIColor
|
||||
/// 工具栏控件文字/图标颜色
|
||||
|
||||
public var toolControlTextColor: UIColor
|
||||
/// 工具栏控件未选中状态的边框颜色
|
||||
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
/// 工具栏中分隔线的颜色
|
||||
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
/// 初始化主题配置
|
||||
/// - Parameters:
|
||||
/// - contentBackgroundColor: 内容区背景色
|
||||
/// - contentTextColor: 内容区文字颜色
|
||||
/// - toolBackgroundColor: 工具栏背景色
|
||||
/// - toolControlTextColor: 工具栏控件颜色
|
||||
/// - toolControlBorderUnselectColor: 控件未选中边框色
|
||||
/// - toolLineColor: 分隔线颜色
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
@@ -48,9 +30,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
// MARK: 内置预设主题
|
||||
|
||||
/// 浅色主题:白底黑字,适合日间阅读
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
@@ -60,7 +39,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 深色主题:黑底白字,适合夜间阅读
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
@@ -70,7 +48,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 黄色护眼主题:暖色调背景,减少蓝光刺激
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@@ -80,7 +57,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 绿色护眼主题:模拟纸质书的柔和绿色背景
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@@ -90,7 +66,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 粉色主题:柔和粉色背景
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@@ -100,7 +75,6 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
/// 蓝色主题:冷色调蓝色背景
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
@@ -111,15 +85,12 @@ public struct RDEPUBReaderTheme: Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CSS 颜色转换
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
/// 将内容区背景色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
}
|
||||
|
||||
/// 将内容区文字颜色转换为 CSS 颜色字符串,注入 EPUB 的 HTML 中
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
}
|
||||
|
||||
@@ -4,17 +4,14 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面交互控制器
|
||||
|
||||
/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑
|
||||
/// 在 DTCoreText 排版结果和用户交互之间充当桥梁
|
||||
/// 提供字符索引查找、选择范围计算、选择矩形计算等功能
|
||||
final class RDEPUBPageInteractionController {
|
||||
|
||||
var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
private var dtLayoutFrame: DTCoreTextLayoutFrame?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
|
||||
dtLayoutFrame = layoutFrame
|
||||
if let layoutFrame, let page {
|
||||
@@ -25,9 +22,6 @@ final class RDEPUBPageInteractionController {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Hit Testing
|
||||
|
||||
/// 将视图坐标系的触摸点转为字符索引(对齐 WXRead 的 stringIndexForPoint:)
|
||||
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
|
||||
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
|
||||
return characterIndex(at: localPoint)
|
||||
@@ -36,7 +30,6 @@ final class RDEPUBPageInteractionController {
|
||||
func characterIndex(at point: CGPoint) -> Int? {
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
// Attachment rect priority (6pt inset for easier tapping)
|
||||
for attachment in snapshot.attachments {
|
||||
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
|
||||
return attachment.stringRange.location
|
||||
@@ -63,8 +56,6 @@ final class RDEPUBPageInteractionController {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Selection Range
|
||||
|
||||
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
|
||||
guard let start = characterIndex(at: startPoint),
|
||||
let end = characterIndex(at: endPoint) else { return nil }
|
||||
@@ -73,8 +64,6 @@ final class RDEPUBPageInteractionController {
|
||||
return NSRange(location: lower, length: max(upper - lower, 1))
|
||||
}
|
||||
|
||||
// MARK: - Selection Rects
|
||||
|
||||
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
|
||||
guard let snapshot else { return [] }
|
||||
var rects: [CGRect] = []
|
||||
@@ -136,8 +125,6 @@ final class RDEPUBPageInteractionController {
|
||||
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
|
||||
}
|
||||
|
||||
// MARK: - Caret Rect
|
||||
|
||||
func caretRect(at index: Int) -> CGRect? {
|
||||
guard let snapshot else { return nil }
|
||||
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
|
||||
@@ -159,8 +146,6 @@ final class RDEPUBPageInteractionController {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
|
||||
var bestLine: RDEPUBPageLine?
|
||||
var bestDistance: CGFloat = .greatestFiniteMagnitude
|
||||
@@ -196,6 +181,7 @@ final class RDEPUBPageInteractionController {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
||||
guard let dtLayoutFrame, let snapshot else { return nil }
|
||||
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
|
||||
|
||||
@@ -4,63 +4,56 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面排版快照数据结构
|
||||
|
||||
/// 文本行信息
|
||||
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
|
||||
struct RDEPUBPageLine {
|
||||
/// 行内文本在 chapterContent 中的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// 行的框架(相对于排版区域)
|
||||
|
||||
let frame: CGRect
|
||||
/// 行的基线原点
|
||||
|
||||
let baselineOrigin: CGPoint
|
||||
/// 基线以上的高度(升部)
|
||||
|
||||
let ascent: CGFloat
|
||||
/// 基线以下的高度(降部)
|
||||
|
||||
let descent: CGFloat
|
||||
/// 行间距
|
||||
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
/// 文本 Run 信息
|
||||
/// 描述一行内某个连续绘制单元(如普通文字或附件)
|
||||
struct RDEPUBPageRun {
|
||||
/// Run 内文本的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// Run 的绘制框架
|
||||
|
||||
let frame: CGRect
|
||||
/// 是否为附件(图片等)
|
||||
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
/// 附件信息
|
||||
/// 描述嵌入在文本中的图片或其他附件
|
||||
struct RDEPUBPageAttachment {
|
||||
/// 附件在文本中的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// 附件的显示框架
|
||||
|
||||
let frame: CGRect
|
||||
/// 附件的建议显示尺寸
|
||||
|
||||
let displaySize: CGSize
|
||||
/// 附件的布局方式(行内/浮动等)
|
||||
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
/// 附件类型(封面图/普通图片等)
|
||||
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
// MARK: - 页面排版快照
|
||||
|
||||
/// 页面排版快照
|
||||
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
|
||||
/// 用于支持文本选择、高亮渲染和搜索结果定位
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let lines: [RDEPUBPageLine]
|
||||
|
||||
let runs: [RDEPUBPageRun]
|
||||
|
||||
let attachments: [RDEPUBPageAttachment]
|
||||
|
||||
let pageContentRange: NSRange
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
let layoutFrame: DTCoreTextLayoutFrame
|
||||
#endif
|
||||
|
||||
@@ -128,6 +121,7 @@ struct RDEPUBPageLayoutSnapshot {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func build(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
page: RDEPUBTextPage
|
||||
|
||||
@@ -1,47 +1,43 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 覆盖层装饰类型
|
||||
|
||||
/// 覆盖层装饰的数据结构
|
||||
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
/// 装饰类型枚举
|
||||
|
||||
enum Kind: String {
|
||||
/// 文本选区(蓝色半透明)
|
||||
|
||||
case selection
|
||||
/// 用户高亮标注
|
||||
|
||||
case highlight
|
||||
/// 用户划线标注
|
||||
|
||||
case underline
|
||||
/// 搜索匹配项(普通)
|
||||
|
||||
case search
|
||||
/// 搜索匹配项(当前高亮)
|
||||
|
||||
case activeSearch
|
||||
/// 定位指示(跳转到位置时的动画目标)
|
||||
|
||||
case locate
|
||||
}
|
||||
|
||||
/// 装饰类型
|
||||
var kind: Kind
|
||||
/// 装饰对应的绝对文本范围
|
||||
|
||||
var absoluteRange: NSRange
|
||||
/// 装饰的绘制矩形数组(每行一个矩形)
|
||||
|
||||
var rects: [CGRect]
|
||||
/// 装饰的颜色
|
||||
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
// MARK: - 选择覆盖层视图
|
||||
|
||||
/// 文本选择和装饰的覆盖层绘制视图
|
||||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||||
class RDEPUBSelectionOverlayView: UIView {
|
||||
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
private(set) var selectionRange: NSRange?
|
||||
|
||||
private var selectionRects: [CGRect] = []
|
||||
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
|
||||
private let selectionVerticalAdjustment: CGFloat = -1
|
||||
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
|
||||
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||
|
||||
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
|
||||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applyHighlights(
|
||||
_ highlights: [RDEPUBHighlight],
|
||||
to content: NSMutableAttributedString,
|
||||
@@ -47,12 +42,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将搜索命中高亮以富文本背景色形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
@@ -78,13 +67,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建页面的背景和前景装饰数组(搜索高亮为背景,下划线批注为前景)
|
||||
/// - Parameters:
|
||||
/// - page: 目标文本页
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - interactionController: 用于计算选区矩形的交互控制器
|
||||
/// - Returns: 分离的背景和前景装饰数组
|
||||
func buildDecorations(
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight],
|
||||
|
||||
@@ -5,10 +5,9 @@ import Foundation
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
@@ -27,22 +26,46 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
enum SelectionInteractionState: Equatable {
|
||||
case idle
|
||||
case selectionPending
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var currentChapterCFIMap: RDEPUBCFIMap?
|
||||
private var currentChapterFragmentOffsets: [String: Int] = [:]
|
||||
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
|
||||
|
||||
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
||||
|
||||
private var selectionInteractionState: SelectionInteractionState = .idle
|
||||
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
var selectionTapSuppressionDidChange: ((Bool) -> Void)?
|
||||
|
||||
var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
||||
let view = RDEPUBTextPageRenderView()
|
||||
view.backgroundColor = .clear
|
||||
@@ -53,10 +76,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}()
|
||||
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let interactionController = RDEPUBPageInteractionController()
|
||||
|
||||
private let selectionController = RDEPUBTextSelectionController()
|
||||
|
||||
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
|
||||
@@ -91,7 +116,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
gesture.isEnabled = false
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
@@ -102,8 +126,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return gesture
|
||||
}()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content.view"
|
||||
@@ -114,10 +136,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(selectionLoupeView)
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
selectionLoupeView.isHidden = true
|
||||
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
@@ -130,17 +154,30 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
self.updateAccessibilityDecorationSummary()
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
selectionController.interactionStateDidChange = { [weak self] state in
|
||||
self?.handleSelectionControllerStateChange(state)
|
||||
}
|
||||
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
||||
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
|
||||
selectionController.chapterFragmentOffsetsProvider = { [weak self] in
|
||||
self?.currentChapterFragmentOffsets ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - First Responder
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
|
||||
longPressGestureRecognizer
|
||||
}
|
||||
|
||||
var isSelectionInteractionInProgress: Bool {
|
||||
selectionInteractionState != .idle
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
action == #selector(rd_copy(_:))
|
||||
|| action == #selector(rd_highlight(_:))
|
||||
@@ -159,8 +196,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
performSelectionAction(.annotate)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
@@ -181,19 +216,22 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Configure
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
currentChapterCFIMap = chapterCFIMap
|
||||
currentChapterFragmentOffsets = chapterFragmentOffsets
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
@@ -228,7 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
// 注入高亮/下划线自定义属性(对齐 WXRead 的 WRChapterData.addHighlightInRange:)
|
||||
|
||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
@@ -263,7 +301,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentSearchState = nil
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
selectionLoupeView.dismiss()
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
@@ -271,8 +311,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - 选择操作
|
||||
|
||||
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
let selection = currentSelection ?? menuSelection
|
||||
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
||||
@@ -280,8 +318,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
// MARK: - Cover Image
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
@@ -327,9 +363,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dark Image Adjustment
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func darkImageAdjustedContentIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
@@ -409,8 +444,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:)
|
||||
|
||||
private func applyHighlightsToContent(
|
||||
_ content: NSMutableAttributedString,
|
||||
highlights: [RDEPUBHighlight],
|
||||
@@ -435,8 +468,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Normalization
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
|
||||
@@ -463,9 +494,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
// MARK: - CoreText Layout
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
@@ -502,8 +532,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
layoutIfNeeded()
|
||||
@@ -514,12 +542,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
panGestureRecognizer.isEnabled = true
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.selecting)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .changed:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .ended:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -528,17 +563,46 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
layoutIfNeeded()
|
||||
let point = gesture.location(in: coreTextRenderView ?? self)
|
||||
if gesture.state == .began, activeSelectionHandle == nil {
|
||||
if let renderView = coreTextRenderView,
|
||||
let handle = renderView.selectionHandle(at: point) {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
|
||||
if let activeSelectionHandle, let renderView = coreTextRenderView {
|
||||
selectionController.updateSelection(
|
||||
byAdjusting: activeSelectionHandle,
|
||||
at: point,
|
||||
renderView: renderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
updateSelectionLoupe(for: point)
|
||||
} else {
|
||||
selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
if selectionController.isSelecting {
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -547,7 +611,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: overlayView)
|
||||
if currentSelection != nil {
|
||||
if let renderView = coreTextRenderView {
|
||||
let renderPoint = gesture.location(in: renderView)
|
||||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||||
return
|
||||
}
|
||||
if currentSelection != nil {
|
||||
if renderView.selectionContains(renderPoint) {
|
||||
showSelectionMenuIfNeeded()
|
||||
return
|
||||
}
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
} else if currentSelection != nil {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
@@ -563,6 +640,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
guard let highlight = highlight(at: point),
|
||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||
return
|
||||
}
|
||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
||||
@@ -589,6 +667,57 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
#if canImport(DTCoreText)
|
||||
guard let renderView = coreTextRenderView else { return }
|
||||
let localPoint = convert(point, from: renderView)
|
||||
selectionLoupeView.present(
|
||||
sourceView: renderView,
|
||||
focusPoint: point,
|
||||
hostBounds: bounds.inset(by: contentInsets),
|
||||
targetPoint: localPoint
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
|
||||
switch state {
|
||||
case .idle:
|
||||
if currentSelection == nil, activeSelectionHandle == nil {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
case .selecting:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
case .selectionActive:
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
case .adjustingHandle:
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
|
||||
let previousTapSuppressed = selectionInteractionState != .idle
|
||||
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
selectionInteractionState = state
|
||||
let currentTapSuppressed = selectionInteractionState != .idle
|
||||
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
if previousTapSuppressed != currentTapSuppressed {
|
||||
selectionTapSuppressionDidChange?(currentTapSuppressed)
|
||||
}
|
||||
if previousPagingSuppressed != currentPagingSuppressed {
|
||||
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
|
||||
switch state {
|
||||
case .idle, .selectionPending, .selectionActive:
|
||||
return false
|
||||
case .selecting, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var coreTextRenderView: RDEPUBTextPageRenderView? {
|
||||
#if canImport(DTCoreText)
|
||||
return coreTextContentView
|
||||
@@ -678,23 +807,140 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return overlayView.convert(fallbackRect, to: self)
|
||||
}
|
||||
|
||||
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
if selectionInteractionState == .selectionPending {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return false
|
||||
}
|
||||
let renderPoint = convert(point, to: renderView)
|
||||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||||
return true
|
||||
}
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
||||
return true
|
||||
}
|
||||
switch selectionInteractionState {
|
||||
case .idle:
|
||||
return false
|
||||
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
#if canImport(DTCoreText)
|
||||
guard selectionInteractionState == .idle,
|
||||
activeSelectionHandle == nil,
|
||||
currentPage != nil,
|
||||
let touch = touches.first,
|
||||
let renderView = coreTextRenderView else {
|
||||
return
|
||||
}
|
||||
let point = touch.location(in: renderView)
|
||||
guard renderView.selectionHandle(at: point) == nil else { return }
|
||||
updateSelectionInteractionState(.selectionPending)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
private func resetSelectionPendingIfNeeded() {
|
||||
guard selectionInteractionState == .selectionPending else { return }
|
||||
if currentSelection != nil {
|
||||
updateSelectionInteractionState(.selectionActive)
|
||||
} else {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
return selectionController.isSelecting
|
||||
if selectionController.isSelecting {
|
||||
return true
|
||||
}
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return false
|
||||
}
|
||||
let point = pan.location(in: renderView)
|
||||
return renderView.selectionHandle(at: point) != nil
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer {
|
||||
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
let point = longPress.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === tapGestureRecognizer {
|
||||
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
|
||||
return false
|
||||
}
|
||||
if selectionController.hasActiveSelection {
|
||||
if let renderView = coreTextRenderView {
|
||||
let point = tapGestureRecognizer.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
let point = tapGestureRecognizer.location(in: overlayView)
|
||||
return attachmentText(at: point) != nil || highlight(at: point) != nil
|
||||
if attachmentText(at: point) != nil || highlight(at: point) != nil {
|
||||
return true
|
||||
}
|
||||
return currentPage != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldReceive touch: UITouch
|
||||
) -> Bool {
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
|
||||
let point = touch.location(in: renderView)
|
||||
if let handle = renderView.selectionHandle(at: point) {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
@@ -703,9 +949,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor Extension
|
||||
private final class RDEPUBSelectionLoupeView: UIView {
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
private let magnification: CGFloat = 1.45
|
||||
|
||||
private let captureSize = CGSize(width: 84, height: 84)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
|
||||
isUserInteractionEnabled = false
|
||||
backgroundColor = .clear
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.18
|
||||
layer.shadowRadius = 10
|
||||
layer.shadowOffset = CGSize(width: 0, height: 5)
|
||||
|
||||
imageView.frame = bounds
|
||||
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
imageView.layer.cornerRadius = bounds.width / 2
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.layer.borderWidth = 1.5
|
||||
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
|
||||
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
|
||||
|
||||
let targetCenter = CGPoint(
|
||||
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
|
||||
y: min(
|
||||
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
|
||||
hostBounds.maxY - bounds.height / 2
|
||||
)
|
||||
)
|
||||
|
||||
center = targetCenter
|
||||
if isHidden {
|
||||
alpha = 0
|
||||
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
|
||||
isHidden = false
|
||||
UIView.animate(withDuration: 0.12) {
|
||||
self.alpha = 1
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
guard !isHidden else { return }
|
||||
isHidden = true
|
||||
alpha = 0
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: captureSize)
|
||||
return renderer.image { context in
|
||||
let cgContext = context.cgContext
|
||||
cgContext.setFillColor(UIColor.systemBackground.cgColor)
|
||||
cgContext.fill(CGRect(origin: .zero, size: captureSize))
|
||||
cgContext.translateBy(
|
||||
x: captureSize.width / 2 - focusPoint.x * magnification,
|
||||
y: captureSize.height / 2 - focusPoint.y * magnification
|
||||
)
|
||||
cgContext.scaleBy(x: magnification, y: magnification)
|
||||
sourceView.layer.render(in: cgContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_isDarkReaderBackground: Bool {
|
||||
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import UIKit
|
||||
|
||||
/// 原生文本渲染路径的页级背景覆盖层。
|
||||
/// 继承自 RDEPUBSelectionOverlayView,负责绘制位于正文下方的页内装饰(如搜索高亮背景、批注背景等)。
|
||||
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
|
||||
|
||||
@@ -3,10 +3,13 @@ import UIKit
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染。
|
||||
/// 对齐 WXRead 的 WRPageView:在同一 drawRect 中绘制高亮背景、文字和选区。
|
||||
final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
enum SelectionHandle {
|
||||
case start
|
||||
case end
|
||||
}
|
||||
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
@@ -19,26 +22,27 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于高亮绘制的 attributed string(包含 com.rdreader.highlight 等自定义属性)
|
||||
var attributedDisplayContent: NSAttributedString? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 选区绘制(对齐 WXRead 的 _drawSelectionInContext:)
|
||||
|
||||
/// 选区矩形数组,由 RDEPUBTextSelectionController 设置
|
||||
var selectionRects: [CGRect] = [] {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
/// 选区颜色(蓝色半透明,对齐 WXRead)
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
|
||||
|
||||
// MARK: - Init
|
||||
private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
|
||||
|
||||
private let selectionHandleStemWidth: CGFloat = 2.5
|
||||
|
||||
private let selectionHandleKnobRadius: CGFloat = 7
|
||||
|
||||
private let selectionHandleHitSlop: CGFloat = 20
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -51,30 +55,23 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Draw
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,对齐 WXRead 的 drawHighlightsInContext:)
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方)
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - 高亮绘制(对齐 WXRead 的 WRCoreTextLayoutFrame.drawHighlightsInContext:)
|
||||
|
||||
private func drawHighlights(
|
||||
in context: CGContext,
|
||||
attributedString: NSAttributedString,
|
||||
@@ -82,7 +79,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
|
||||
// 绘制高亮背景
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
@@ -92,7 +88,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制下划线
|
||||
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
@@ -107,7 +102,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算指定字符范围的视觉矩形(对齐 WXRead 的 rectsForRange: 算法)
|
||||
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
||||
var rects: [CGRect] = []
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
||||
@@ -131,14 +125,110 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
return rects
|
||||
}
|
||||
|
||||
// MARK: - 选区绘制
|
||||
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
for rect in selectionRects {
|
||||
context.fill(rect)
|
||||
}
|
||||
drawSelectionHandles(in: context)
|
||||
}
|
||||
|
||||
func selectionHandle(at point: CGPoint) -> SelectionHandle? {
|
||||
guard let handleGeometry = selectionHandleGeometry else { return nil }
|
||||
|
||||
let startDistance = point.distance(to: handleGeometry.startKnobCenter)
|
||||
let endDistance = point.distance(to: handleGeometry.endKnobCenter)
|
||||
let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
|
||||
|
||||
let startMatched = startDistance <= maxDistance
|
||||
let endMatched = endDistance <= maxDistance
|
||||
|
||||
switch (startMatched, endMatched) {
|
||||
case (true, true):
|
||||
return startDistance <= endDistance ? .start : .end
|
||||
case (true, false):
|
||||
return .start
|
||||
case (false, true):
|
||||
return .end
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectionContains(_ point: CGPoint) -> Bool {
|
||||
selectionRects.contains { rect in
|
||||
rect.insetBy(dx: -6, dy: -8).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
|
||||
guard let firstRect = selectionRects.first,
|
||||
let lastRect = selectionRects.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let startKnobCenter = CGPoint(
|
||||
x: firstRect.minX,
|
||||
y: firstRect.minY - selectionHandleKnobRadius
|
||||
)
|
||||
let endKnobCenter = CGPoint(
|
||||
x: lastRect.maxX,
|
||||
y: lastRect.maxY + selectionHandleKnobRadius
|
||||
)
|
||||
|
||||
return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
|
||||
}
|
||||
|
||||
private func drawSelectionHandles(in context: CGContext) {
|
||||
guard let firstRect = selectionRects.first,
|
||||
let lastRect = selectionRects.last else {
|
||||
return
|
||||
}
|
||||
|
||||
context.saveGState()
|
||||
context.setFillColor(selectionHandleColor.cgColor)
|
||||
|
||||
let stemHalfWidth = selectionHandleStemWidth / 2
|
||||
|
||||
let startStem = CGRect(
|
||||
x: firstRect.minX - stemHalfWidth,
|
||||
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
||||
width: selectionHandleStemWidth,
|
||||
height: firstRect.height + selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fill(startStem)
|
||||
let startKnob = CGRect(
|
||||
x: firstRect.minX - selectionHandleKnobRadius,
|
||||
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
||||
width: selectionHandleKnobRadius * 2,
|
||||
height: selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fillEllipse(in: startKnob)
|
||||
|
||||
let endStem = CGRect(
|
||||
x: lastRect.maxX - stemHalfWidth,
|
||||
y: lastRect.minY,
|
||||
width: selectionHandleStemWidth,
|
||||
height: lastRect.height + selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fill(endStem)
|
||||
let endKnob = CGRect(
|
||||
x: lastRect.maxX - selectionHandleKnobRadius,
|
||||
y: lastRect.maxY,
|
||||
width: selectionHandleKnobRadius * 2,
|
||||
height: selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fillEllipse(in: endKnob)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGPoint {
|
||||
|
||||
func distance(to point: CGPoint) -> CGFloat {
|
||||
hypot(x - point.x, y - point.y)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import UIKit
|
||||
|
||||
/// 负责管理文本选区和 CoreText 命中测试。
|
||||
/// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。
|
||||
final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
enum BoundaryHandle {
|
||||
case start
|
||||
case end
|
||||
}
|
||||
|
||||
enum InteractionState: Equatable {
|
||||
case idle
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
private enum SelectionGranularity {
|
||||
case character
|
||||
case word
|
||||
}
|
||||
|
||||
private(set) var isSelecting = false
|
||||
|
||||
private var selectionStartIndex: Int = NSNotFound
|
||||
|
||||
private var selectionEndIndex: Int = NSNotFound
|
||||
|
||||
/// 选区变化回调,通知视图层更新 UI
|
||||
private var activeGranularity: SelectionGranularity = .character
|
||||
|
||||
private var selectionAnchorIndex: Int = NSNotFound
|
||||
|
||||
private(set) var interactionState: InteractionState = .idle {
|
||||
didSet {
|
||||
guard interactionState != oldValue else { return }
|
||||
interactionStateDidChange?(interactionState)
|
||||
}
|
||||
}
|
||||
|
||||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||||
/// 获取当前页面数据
|
||||
|
||||
var interactionStateDidChange: ((InteractionState) -> Void)?
|
||||
|
||||
var pageProvider: (() -> RDEPUBTextPage?)?
|
||||
|
||||
var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)?
|
||||
|
||||
var chapterFragmentOffsetsProvider: (() -> [String: Int])?
|
||||
|
||||
var hasActiveSelection: Bool {
|
||||
selectedAbsoluteRange != nil
|
||||
}
|
||||
@@ -37,11 +70,14 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
setInteractionState(.selecting)
|
||||
beginSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .changed:
|
||||
setInteractionState(.selecting)
|
||||
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .ended:
|
||||
isSelecting = false
|
||||
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
|
||||
case .cancelled, .failed:
|
||||
clearSelection(renderView: renderView)
|
||||
default:
|
||||
@@ -59,14 +95,46 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
switch gesture.state {
|
||||
case .began, .changed:
|
||||
setInteractionState(.selecting)
|
||||
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .ended, .cancelled, .failed:
|
||||
isSelecting = false
|
||||
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelection(
|
||||
byAdjusting handle: BoundaryHandle,
|
||||
at point: CGPoint,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let renderView,
|
||||
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView),
|
||||
selectedAbsoluteRange != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
setInteractionState(.adjustingHandle)
|
||||
|
||||
switch handle {
|
||||
case .start:
|
||||
selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start)
|
||||
if selectionEndIndex != NSNotFound {
|
||||
selectionStartIndex = min(selectionStartIndex, selectionEndIndex)
|
||||
}
|
||||
case .end:
|
||||
selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end)
|
||||
if selectionStartIndex != NSNotFound {
|
||||
selectionEndIndex = max(selectionEndIndex, selectionStartIndex)
|
||||
}
|
||||
}
|
||||
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
|
||||
guard let absoluteRange = selectedAbsoluteRange else { return nil }
|
||||
return interactionController.menuAnchorRect(for: absoluteRange)
|
||||
@@ -74,8 +142,11 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
|
||||
isSelecting = false
|
||||
selectionAnchorIndex = NSNotFound
|
||||
activeGranularity = .character
|
||||
selectionStartIndex = NSNotFound
|
||||
selectionEndIndex = NSNotFound
|
||||
setInteractionState(.idle)
|
||||
renderView?.selectionRects = []
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
onSelectionChanged?(nil)
|
||||
@@ -92,9 +163,141 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
selectionStartIndex = index
|
||||
selectionEndIndex = index
|
||||
selectionAnchorIndex = index
|
||||
activeGranularity = .word
|
||||
isSelecting = true
|
||||
applySelection(
|
||||
range: selectionRange(for: index, granularity: .word),
|
||||
renderView: renderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
}
|
||||
|
||||
private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? {
|
||||
guard let page = pageProvider?() else { return nil }
|
||||
|
||||
switch granularity {
|
||||
case .character:
|
||||
return NSRange(location: focusIndex, length: 1)
|
||||
case .word:
|
||||
if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) {
|
||||
return wordRange
|
||||
}
|
||||
return NSRange(location: focusIndex, length: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int {
|
||||
guard let page = pageProvider?() else { return index }
|
||||
let text = page.chapterContent.string as NSString
|
||||
guard let wordRange = wordRange(containing: index, in: text) else {
|
||||
return index
|
||||
}
|
||||
|
||||
switch handle {
|
||||
case .start:
|
||||
return wordRange.location
|
||||
case .end:
|
||||
return max(wordRange.location + wordRange.length - 1, wordRange.location)
|
||||
}
|
||||
}
|
||||
|
||||
private func wordRange(containing index: Int, in text: NSString) -> NSRange? {
|
||||
guard text.length > 0 else { return nil }
|
||||
let safeIndex = min(max(index, 0), max(text.length - 1, 0))
|
||||
if let scalar = UnicodeScalar(text.character(at: safeIndex)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
return nearestWordRange(to: safeIndex, in: text)
|
||||
?? text.rangeOfComposedCharacterSequence(at: safeIndex)
|
||||
}
|
||||
let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex)
|
||||
let probeRange = NSRange(location: safeIndex, length: 1)
|
||||
var matchedWordRange: NSRange?
|
||||
|
||||
text.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: text.length),
|
||||
options: [.byWords, .substringNotRequired]
|
||||
) { _, substringRange, _, stop in
|
||||
guard substringRange.length > 0 else { return }
|
||||
if NSIntersectionRange(substringRange, probeRange).length > 0
|
||||
|| NSLocationInRange(characterRange.location, substringRange) {
|
||||
matchedWordRange = substringRange
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
if let matchedWordRange {
|
||||
let trimmedRange = trimmed(range: matchedWordRange, in: text)
|
||||
if trimmedRange.length > 0 {
|
||||
return trimmedRange
|
||||
}
|
||||
}
|
||||
|
||||
return characterRange
|
||||
}
|
||||
|
||||
private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? {
|
||||
var nearestRange: NSRange?
|
||||
var nearestDistance = Int.max
|
||||
|
||||
text.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: text.length),
|
||||
options: [.byWords, .substringNotRequired]
|
||||
) { _, substringRange, _, _ in
|
||||
guard substringRange.length > 0 else { return }
|
||||
let trimmedRange = self.trimmed(range: substringRange, in: text)
|
||||
guard trimmedRange.length > 0 else { return }
|
||||
|
||||
let distance: Int
|
||||
if index < trimmedRange.location {
|
||||
distance = trimmedRange.location - index
|
||||
} else if index >= trimmedRange.location + trimmedRange.length {
|
||||
distance = index - (trimmedRange.location + trimmedRange.length - 1)
|
||||
} else {
|
||||
distance = 0
|
||||
}
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestRange = trimmedRange
|
||||
}
|
||||
}
|
||||
|
||||
return nearestRange
|
||||
}
|
||||
|
||||
private func trimmed(range: NSRange, in text: NSString) -> NSRange {
|
||||
guard range.length > 0 else { return range }
|
||||
|
||||
var lowerBound = range.location
|
||||
var upperBound = range.location + range.length
|
||||
|
||||
while lowerBound < upperBound,
|
||||
let scalar = UnicodeScalar(text.character(at: lowerBound)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
lowerBound += 1
|
||||
}
|
||||
|
||||
while upperBound > lowerBound,
|
||||
let scalar = UnicodeScalar(text.character(at: upperBound - 1)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
upperBound -= 1
|
||||
}
|
||||
|
||||
return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0))
|
||||
}
|
||||
|
||||
private func applySelection(
|
||||
range: NSRange?,
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let range, range.location != NSNotFound, range.length > 0 else {
|
||||
clearSelection(renderView: renderView)
|
||||
return
|
||||
}
|
||||
selectionStartIndex = range.location
|
||||
selectionEndIndex = range.location + range.length - 1
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
@@ -103,11 +306,24 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard selectionStartIndex != NSNotFound,
|
||||
guard selectionAnchorIndex != NSNotFound,
|
||||
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
|
||||
return
|
||||
}
|
||||
selectionEndIndex = index
|
||||
|
||||
let clampedIndex: Int
|
||||
switch activeGranularity {
|
||||
case .character:
|
||||
clampedIndex = index
|
||||
case .word:
|
||||
clampedIndex = snappedBoundaryIndex(
|
||||
for: index,
|
||||
handle: index >= selectionAnchorIndex ? .end : .start
|
||||
)
|
||||
}
|
||||
|
||||
selectionStartIndex = selectionAnchorIndex
|
||||
selectionEndIndex = clampedIndex
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
@@ -122,9 +338,14 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
}
|
||||
|
||||
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
|
||||
setInteractionState(isSelecting ? .selecting : .selectionActive)
|
||||
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
|
||||
}
|
||||
|
||||
private func setInteractionState(_ state: InteractionState) {
|
||||
interactionState = state
|
||||
}
|
||||
|
||||
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||
guard absoluteRange.location != NSNotFound,
|
||||
absoluteRange.length > 0,
|
||||
@@ -137,31 +358,32 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let globalStart = absoluteRange.location
|
||||
let globalEnd = absoluteRange.location + absoluteRange.length
|
||||
let startAnchor = RDEPUBTextAnchor(
|
||||
fileIndex: page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: globalStart
|
||||
)
|
||||
let endAnchor = RDEPUBTextAnchor(
|
||||
fileIndex: page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: globalEnd
|
||||
)
|
||||
let chapterData = makeChapterData(for: page)
|
||||
let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
|
||||
return RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
),
|
||||
location: location,
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: page.chapterIndex,
|
||||
spineIndex: page.spineIndex,
|
||||
href: page.href,
|
||||
title: page.chapterTitle,
|
||||
attributedContent: page.chapterContent,
|
||||
fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:],
|
||||
cfiMap: chapterCFIMapProvider?(),
|
||||
pageBreakReasons: [],
|
||||
pages: [page]
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// UIColor 十六进制颜色扩展,提供从 HEX 字符串创建颜色的便捷方法。
|
||||
extension UIColor {
|
||||
/// 从十六进制字符串创建颜色
|
||||
/// - Parameters:
|
||||
/// - rdHexString: 颜色 HEX 字符串,支持带或不带 "#" 前缀(如 "#FF5733" 或 "FF5733"),必须为 6 位
|
||||
/// - alpha: 透明度(0.0 ~ 1.0)
|
||||
|
||||
convenience init?(rdHexString: String, alpha: CGFloat) {
|
||||
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value = value.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
Reference in New Issue
Block a user