ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBReadingSession.swift
shenlei d15f20b097 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>
2026-06-24 17:47:24 +08:00

371 lines
13 KiB
Swift

import Foundation
public final class RDEPUBReadingSession {
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
public let publication: RDEPUBPublication
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
public private(set) var activePages: [EPUBPage] = []
public private(set) var activeChapters: [EPUBChapterInfo] = []
public private(set) var stagedPages: [EPUBPage]?
public private(set) var stagedChapters: [EPUBChapterInfo]?
public private(set) var stagedRestoreLocation: RDEPUBLocation?
public private(set) var pendingNavigationLocation: RDEPUBLocation?
public private(set) var pendingNavigationPageNum: Int?
public private(set) var pendingNavigationHighlightRangeInfo: String?
public private(set) var currentViewport: RDEPUBViewport?
public private(set) var currentReadingContext: RDEPUBReadingContext?
public init(publication: RDEPUBPublication) {
self.publication = publication
}
public var resourceResolver: RDEPUBResourceResolver {
publication.resourceResolver
}
public func transition(to state: RDEPUBNavigatorState) {
navigatorState = state
}
public func resetRuntimeState() {
navigatorState = .initializing
activePages = []
activeChapters = []
clearPendingNavigation()
clearStagedSnapshot()
currentViewport = nil
currentReadingContext = nil
}
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
activePages = snapshot.pages
activeChapters = snapshot.chapters
}
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
stagedPages = snapshot.pages
stagedChapters = snapshot.chapters
stagedRestoreLocation = restoreLocation
}
public func stagedSnapshot() -> PaginationSnapshot? {
guard let stagedPages, let stagedChapters else {
return nil
}
return (stagedPages, stagedChapters)
}
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
guard navigatorState.isStableForSnapshotApplication,
let stagedPages,
let stagedChapters else {
return nil
}
let restoreLocation = stagedRestoreLocation
clearStagedSnapshot()
return ((stagedPages, stagedChapters), restoreLocation)
}
public func clearStagedSnapshot() {
stagedPages = nil
stagedChapters = nil
stagedRestoreLocation = nil
}
public func clearPendingNavigation() {
pendingNavigationLocation = nil
pendingNavigationPageNum = nil
pendingNavigationHighlightRangeInfo = nil
}
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationLocation else {
return nil
}
guard let spineIndex else {
return pendingNavigationLocation
}
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
return nil
}
return pendingNavigationLocation
}
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationHighlightRangeInfo,
!pendingNavigationHighlightRangeInfo.isEmpty else {
return nil
}
guard let pendingNavigationLocation else {
return pendingNavigationHighlightRangeInfo
}
guard let spineIndex else {
return pendingNavigationHighlightRangeInfo
}
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
return nil
}
return pendingNavigationHighlightRangeInfo
}
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread {
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
}
return page.spineIndex == spineIndex
}
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
if let fixedSpread = page.fixedSpread {
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: fixedSpread.primaryResource.href,
progression: 0,
lastProgression: 1
)
}
guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else {
return nil
}
let progression: Double
if page.totalPagesInChapter <= 1 {
progression = 0
} else {
progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1)
}
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: href,
progression: progression,
lastProgression: progression
)
}
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard currentPageNumber > 0,
activePages.indices.contains(currentPageNumber - 1) else {
return nil
}
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
}
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
guard let location,
let normalizedHref = resourceResolver.normalizedHref(location.href),
let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else {
return 0
}
return spineIndex
}
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
return nil
}
guard let targetHref = resourceResolver.normalizedHref(location.href) else {
return nil
}
if publication.layout == .fixed {
return activePages.firstIndex(where: { page in
if let fixedSpread = page.fixedSpread {
return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) })
}
guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else {
return false
}
return self.resourceResolver.normalizedHref(href) == targetHref
})
}
guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else {
return nil
}
let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex }
guard !candidates.isEmpty else {
return nil
}
if candidates.count == 1 {
return candidates[0].offset
}
let navigationProgression = location.navigationProgression
let localIndex = min(
candidates.count - 1,
max(0, Int(round(navigationProgression * Double(candidates.count - 1))))
)
return candidates[localIndex].offset
}
public func queueNavigation(
to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
bookIdentifier: String?,
targetHighlightRangeInfo: String? = nil
) -> Int? {
guard let normalizedLocation = resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: spineIndex,
bookIdentifier: bookIdentifier
), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else {
return nil
}
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
let shouldKeepPendingNavigation =
normalizedLocation.fragment != nil ||
normalizedLocation.rangeAnchor != nil ||
hasTargetHighlightRangeInfo
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
transition(to: .jumping)
return pageIndex + 1
}
public func updateReadingContext(
pageNumber: Int,
location: RDEPUBLocation,
spineIndex: Int,
chapterIndex: Int?,
bookIdentifier: String?
) {
let normalizedLocation = resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: spineIndex,
bookIdentifier: bookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor,
cfi: location.cfi,
lastCFI: location.lastCFI,
rangeCFI: location.rangeCFI
)
let resource = RDEPUBViewportResource(
href: normalizedLocation.href,
spineIndex: spineIndex,
progression: normalizedLocation.progression,
lastProgression: normalizedLocation.lastProgression,
fragment: normalizedLocation.fragment
)
let viewport = RDEPUBViewport(
resources: [resource],
visiblePageNumber: pageNumber,
chapterIndex: chapterIndex,
isFixedLayout: publication.layout == .fixed
)
currentViewport = viewport
currentReadingContext = RDEPUBReadingContext(
location: normalizedLocation,
viewport: viewport,
pageNumber: pageNumber,
chapterIndex: chapterIndex
)
if pendingNavigationPageNum == pageNumber {
clearPendingNavigation()
}
if navigatorState == .jumping || navigatorState == .moving {
transition(to: .idle)
}
}
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
}
public func makePaginationSnapshot(
pageCounts: [Int],
preferences: RDEPUBPreferences,
layoutContext: RDEPUBNavigatorLayoutContext
) -> PaginationSnapshot {
if publication.layout == .fixed {
let spreads = publication.makeFixedSpreads(
preferences: preferences,
viewportSize: layoutContext.viewportSize
)
let chapters = spreads.enumerated().map { spreadIndex, spread in
EPUBChapterInfo(
spineIndex: spread.primaryResource.spineIndex,
title: spread.primaryResource.title,
pageCount: 1
)
}
let pages = spreads.enumerated().map { spreadIndex, spread in
EPUBPage(
spineIndex: spread.primaryResource.spineIndex,
chapterIndex: spreadIndex,
pageIndexInChapter: 0,
totalPagesInChapter: 1,
chapterTitle: spread.primaryResource.title,
fixedSpread: spread
)
}
return (pages, chapters)
}
var pages: [EPUBPage] = []
var chapters: [EPUBChapterInfo] = []
for (spineIndex, pageCount) in pageCounts.enumerated() {
guard pageCount > 0, spineIndex < publication.spine.count else { continue }
let chapterIndex = chapters.count
let title = publication.spine[spineIndex].title
chapters.append(
EPUBChapterInfo(
spineIndex: spineIndex,
title: title,
pageCount: pageCount
)
)
for pageIndex in 0..<pageCount {
pages.append(
EPUBPage(
spineIndex: spineIndex,
chapterIndex: chapterIndex,
pageIndexInChapter: pageIndex,
totalPagesInChapter: pageCount,
chapterTitle: title,
fixedSpread: nil
)
)
}
}
return (pages, chapters)
}
}