Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBWebView.swift
T

223 lines
8.5 KiB
Swift

// RDEPUBWebView.swift
// EPUB WebView 主类
// 封装 WKWebView 的 EPUB 渲染能力,是 Resource View 层的核心组件。
// 统一管理固定版式和重排模式的加载流程,持有当前渲染状态(spineIndex、pageIndex、
// 主题色、高亮等),并通过委托模式通知上层页面渲染和交互事件。
// 具体逻辑分散在 +Configuration、+FixedLayout、+Reflowable、+JavaScriptBridge、+Search 扩展中。
import UIKit
import WebKit
/// RDEPUBWebView 委托协议,定义 WebView 向上层通知的事件
public protocol RDEPUBWebViewDelegate: AnyObject {
/// 阅读位置更新事件
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// 文本选择变化事件
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
/// 选择菜单动作请求事件
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
/// 内部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
/// 外部链接点击事件
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
/// JavaScript 错误事件
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
/// 页面渲染完成事件
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
/// 委托协议的默认实现(选择菜单动作为可选)
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
}
/// 自定义 WKWebView 子类,支持拷贝/高亮/批注选择菜单
final class RDEPUBAnnotationWebView: WKWebView {
/// 选择菜单动作回调
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override var canBecomeFirstResponder: Bool {
true
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return true
default:
return false
}
}
@objc func rd_copy(_ sender: Any?) {
onSelectionAction?(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
onSelectionAction?(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
onSelectionAction?(.annotate)
}
}
/// EPUB WebView 主类,封装 WKWebView 的 EPUB 渲染能力
/// Resource View 层核心组件,统一管理固定版式和重排模式的渲染
public final class RDEPUBWebView: UIView {
/// 委托对象,接收渲染和交互事件
public weak var delegate: RDEPUBWebViewDelegate?
/// 渲染完成回调(与 delegate 并行使用)
public var onRendered: (() -> Void)?
/// 内部原生装饰解析回调
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
/// 当前关联的出版物
var publication: RDEPUBPublication?
/// 当前渲染请求
var currentRenderRequest: RDEPUBRenderRequest?
/// 内部 WKWebView 实例
var webView: WKWebView?
/// URL Scheme 处理器
var schemeHandler: RDEPUBResourceURLSchemeHandler?
/// 已配置的出版物标识(用于判断是否需要重建 WebView)
var configuredPublicationKey: String?
/// 当前加载签名(用于去重相同请求)
var currentLoadSignature: String?
/// 当前 spine 索引
var currentSpineIndex = 0
/// 当前资源 href
var currentHref = ""
/// 当前页码(从 0 开始)
var currentPageIndex = 0
/// 当前章节总页数
var currentTotalPagesInChapter = 1
/// 当前视口尺寸
var viewportSize: CGSize = .zero
/// 当前内容内边距
var currentPadding: UIEdgeInsets = .zero
/// 当前字号
var currentFontSize: CGFloat = 16
/// 当前行高倍数
var currentLineHeightMultiple: CGFloat = 1.5
/// 当前主题背景色
var currentThemeBackgroundColor: String?
/// 当前主题文字色
var currentThemeTextColor: String?
/// 目标跳转位置
var targetLocation: RDEPUBLocation?
/// 待应用的高亮列表
var pendingHighlights: [RDEPUBHighlight] = []
/// 当前固定版式 spread(固定版式时非空)
var fixedSpread: EPUBFixedSpread?
/// 是否为固定版式模式
var isFixedLayout = false
/// 是否有待处理的进度汇报请求
var pendingProgressionRequest = false
/// 固定版式就绪回退定时器
var fixedLayoutReadyWorkItem: DispatchWorkItem?
/// 当前请求是否已完成渲染
var didRenderCurrentRequest = false
/// 调试日志作用域
let debugScope = "ReaderWebView"
public override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
teardownWebView()
}
public override func layoutSubviews() {
super.layoutSubviews()
webView?.frame = bounds
}
/// 重置所有状态并销毁 WebView
public func reset() {
delegate = nil
onRendered = nil
onDecorationsResolved = nil
publication = nil
currentRenderRequest = nil
currentSpineIndex = 0
currentHref = ""
currentPageIndex = 0
currentTotalPagesInChapter = 1
viewportSize = .zero
currentPadding = .zero
currentFontSize = 16
currentLineHeightMultiple = 1.5
currentThemeBackgroundColor = nil
currentThemeTextColor = nil
targetLocation = nil
pendingHighlights = []
fixedSpread = nil
isFixedLayout = false
pendingProgressionRequest = false
didRenderCurrentRequest = false
teardownWebView()
}
/// 加载渲染请求:配置 WebView、生成签名、分发到固定版式或重排处理
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
configureWebViewIfNeeded(publication: publication)
guard let webView else { return }
self.publication = publication
self.currentRenderRequest = request
self.currentSpineIndex = request.primarySpineIndex
self.currentHref = request.primaryHref
self.didRenderCurrentRequest = false
cancelFixedLayoutReadyFallback()
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
RDEPUBWebViewDebug.log(
debugScope,
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
)
switch request {
case .reflowable(let reflowableRequest):
handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView)
case .fixed(let fixedRequest):
handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView)
}
}
/// 生成页面加载签名
func pageLoadSignature(
publicationKey: String,
request: RDEPUBRenderRequest
) -> String {
switch request {
case .reflowable(let request):
return reflowableLoadSignature(publicationKey: publicationKey, request: request)
case .fixed(let request):
return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request)
}
}
/// 标记渲染完成:通知委托和回调,延迟 100ms 确保布局稳定
func rendered() {
guard !didRenderCurrentRequest else { return }
didRenderCurrentRequest = true
cancelFixedLayoutReadyFallback()
RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self else { return }
self.delegate?.epubWebViewDidFinishRendering(self)
self.onRendered?()
}
}
}