ReadViewSDK/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift
shen 7de661eb54 feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00

329 lines
9.3 KiB
Swift

import Foundation
import UIKit
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
public struct RDEPUBSelection: Codable, Equatable {
public var bookIdentifier: String?
public var location: RDEPUBLocation
public var text: String
public var rangeInfo: String?
public var createdAt: Date
public init(
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
createdAt: Date = Date()
) {
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.createdAt = createdAt
}
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
public var kind: String
public var href: String
public var start: Int
public var end: Int
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
self.start = start
self.end = end
}
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string,
let data = string.data(using: .utf8),
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
rangeInfo.kind == "text-offset",
rangeInfo.end > rangeInfo.start else {
return nil
}
return rangeInfo
}
}
public struct RDEPUBHighlight: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var location: RDEPUBLocation
public var text: String
public var rangeInfo: String?
public var style: RDEPUBHighlightStyle
public var color: String
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight,
color: String = "#F8E16C",
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.style = style
self.color = color
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public var uiColor: UIColor {
UIColor(rdHexString: color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
}
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
case location
case text
case rangeInfo
case style
case color
case note
case createdAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
location = try container.decode(RDEPUBLocation.self, forKey: .location)
text = try container.decode(String.self, forKey: .text)
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle
} else {
style = .highlight
}
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
try container.encode(location, forKey: .location)
try container.encode(text, forKey: .text)
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(createdAt, forKey: .createdAt)
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(highlight: self)
}
}
public struct RDEPUBBookmark: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var location: RDEPUBLocation
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(bookmark: self)
}
}
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var kind: RDEPUBAnnotationKind
public var location: RDEPUBLocation
public var text: String?
public var rangeInfo: String?
public var color: String?
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
kind: RDEPUBAnnotationKind,
location: RDEPUBLocation,
text: String? = nil,
rangeInfo: String? = nil,
color: String? = nil,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.kind = kind
self.location = location
self.text = text?.nilIfEmpty
self.rangeInfo = rangeInfo?.nilIfEmpty
self.color = color?.nilIfEmpty
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public init(highlight: RDEPUBHighlight) {
self.init(
id: highlight.id,
bookIdentifier: highlight.bookIdentifier,
kind: highlight.style == .underline ? .underline : .highlight,
location: highlight.location,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
color: highlight.color,
chapterTitle: nil,
note: highlight.note,
createdAt: highlight.createdAt
)
}
public init(bookmark: RDEPUBBookmark) {
self.init(
id: bookmark.id,
bookIdentifier: bookmark.bookIdentifier,
kind: .bookmark,
location: bookmark.location,
text: nil,
rangeInfo: nil,
color: nil,
chapterTitle: bookmark.chapterTitle,
note: bookmark.note,
createdAt: bookmark.createdAt
)
}
public var bookmark: RDEPUBBookmark? {
guard kind == .bookmark else { return nil }
return RDEPUBBookmark(
id: id,
bookIdentifier: bookIdentifier,
location: location,
chapterTitle: chapterTitle,
note: note,
createdAt: createdAt
)
}
public var highlight: RDEPUBHighlight? {
guard kind == .highlight || kind == .underline,
let text else {
return nil
}
return RDEPUBHighlight(
id: id,
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo,
style: kind == .underline ? .underline : .highlight,
color: color ?? "#F8E16C",
note: note,
createdAt: createdAt
)
}
}