feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理

- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-24 17:47:24 +08:00
co-authored by Claude
parent 7de661eb54
commit d15f20b097
59 changed files with 4522 additions and 7220 deletions
@@ -1,5 +1,17 @@
import UIKit
enum RDEPUBPendingPageMapUpdateKind {
case reconcileFullMap
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
case appendForward
}
struct RDEPUBPendingPageMapUpdate {
let pageMap: RDEPUBBookPageMap
let source: RDEPUBPaginationStateSource
let kind: RDEPUBPendingPageMapUpdateKind
}
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
@@ -34,77 +46,63 @@ final class RDEPUBPresentationRuntime {
navigationStateMachine.transition(to: .presentingWindow)
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingFullPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.pendingFullPageMap = nil
paginationState.pendingPageMapUpdates.removeAll()
paginationState.source = .initialPartial
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
navigationStateMachine.transition(to: .reconcilingFullMap)
context.pendingFullPageMap = bookPageMap
paginationState.pendingFullPageMap = bookPageMap
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .pendingFullMap,
kind: .reconcileFullMap
)
)
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
func commitPendingPageMapUpdateIfNeeded() {
guard let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
guard !readerView.isPageCurlTransitioning else {
RDEPUBBackgroundTrace.log("PageMapCommit", "defer commit reason=pageCurlTransition")
return
}
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate(
update,
at: index,
readerView: readerView,
controller: controller
) {
return
}
}
}
func applyExtendedPartialPageMap(
func queueExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
guard let readerView = context.readerView else { return }
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .asyncExtension
readerView.reloadPageCountOnly()
if let currentLocation,
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .extendPartial(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
)
)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
@@ -115,6 +113,16 @@ final class RDEPUBPresentationRuntime {
paginationState.source = .settingsPreview
}
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
source: .asyncExtension,
kind: .appendForward
)
)
}
func clear() {
paginationState = RDEPUBPaginationState()
navigationStateMachine.transition(to: .idle)
@@ -150,22 +158,17 @@ final class RDEPUBPresentationRuntime {
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
context.pendingFullPageMap = nil
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
paginationState.activePageMap = newPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .fullReplacement
navigationStateMachine.transition(to: .presentingWindow)
applyPageMapToLiveModel(newPageMap, source: .fullReplacement)
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
rebindVisiblePage(
to: newPage,
readerView: readerView
)
}
} else {
readerView.reloadPageCountOnly()
@@ -181,4 +184,184 @@ final class RDEPUBPresentationRuntime {
}
}
}
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
var updates = context.pendingPageMapUpdates
if let existingIndex = updates.firstIndex(where: {
pendingPageMapUpdateKindMatches($0.kind, update.kind)
}) {
let existing = updates[existingIndex]
if shouldReplacePendingPageMapUpdate(existing, with: update) {
updates[existingIndex] = update
}
} else {
updates.append(update)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
commitPendingPageMapUpdateIfNeeded()
}
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
}
}
private func commitPendingPageMapUpdate(
_ update: RDEPUBPendingPageMapUpdate,
at index: Int,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
switch update.kind {
case .reconcileFullMap:
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: update.pageMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
return false
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
removePendingPageMapUpdate(at: index)
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
return true
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
removePendingPageMapUpdate(at: index)
return false
}
case .extendPartial(let currentPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
if let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true
}
rebindVisiblePage(
to: max(currentPageNumber - 1, 0),
readerView: readerView
)
return true
case .appendForward:
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap, source: update.source)
readerView.reloadPageCountOnly()
return true
}
}
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
if readerView.currentDisplayType == .pageCurl {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
} else {
readerView.reloadPageCountOnly()
if pageIndex != readerView.currentPage {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
}
}
private func rebindVisibleLocation(
_ location: RDEPUBLocation,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else {
return false
}
if context.bookPageMap != nil,
context.runtime?.prepareOnDemandChapter(
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: true
) == false {
return false
}
rebindVisiblePage(
to: max(targetPageNumber - 1, 0),
readerView: readerView
)
return true
}
private func applyPageMapToLiveModel(
_ pageMap: RDEPUBBookPageMap,
source: RDEPUBPaginationStateSource
) {
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = pageMap
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
paginationState.activePageMap = pageMap
paginationState.source = source
}
private func removePendingPageMapUpdate(at index: Int) {
var updates = context.pendingPageMapUpdates
guard updates.indices.contains(index) else { return }
updates.remove(at: index)
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
let updates = context.pendingPageMapUpdates.filter { update in
update.pageMap.totalChapters > liveMap.totalChapters
|| (
update.pageMap.totalChapters == liveMap.totalChapters
&& update.pageMap.totalPages > liveMap.totalPages
)
}
context.pendingPageMapUpdates = updates
paginationState.pendingPageMapUpdates = updates
}
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
switch kind {
case .extendPartial:
return 3
case .appendForward:
return 2
case .reconcileFullMap:
return 1
}
}
private func pendingPageMapUpdateKindMatches(
_ lhs: RDEPUBPendingPageMapUpdateKind,
_ rhs: RDEPUBPendingPageMapUpdateKind
) -> Bool {
switch (lhs, rhs) {
case (.reconcileFullMap, .reconcileFullMap),
(.appendForward, .appendForward),
(.extendPartial, .extendPartial):
return true
default:
return false
}
}
private func shouldReplacePendingPageMapUpdate(
_ existing: RDEPUBPendingPageMapUpdate,
with candidate: RDEPUBPendingPageMapUpdate
) -> Bool {
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|| (
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
}