- 拆分 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>
371 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|