Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift
T
2026-05-26 09:19:37 +08:00

188 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RDEPUBJavaScriptBridge.swift
// 原生与 WebView 之间的 JavaScript 桥接层
// 定义所有 JS 消息类型(progression/selection/link/error 等),
// 生成注入到 WebView 的用户脚本,以及分页渲染、搜索高亮等执行脚本。
import Foundation
/// JS 桥接消息类型枚举,每种消息对应 WebView 向原生发送的一种事件
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
/// 阅读进度变化事件
case progressionChanged = "ssReaderProgressionChanged"
/// 文本选择变化事件
case selectionChanged = "ssReaderSelectionChanged"
/// 内部链接点击事件(ss-reader:// 协议)
case internalLink = "ssReaderInternalLink"
/// 外部链接点击事件(http/https/mailto/tel
case externalLink = "ssReaderExternalLink"
/// JavaScript 运行时错误事件
case javaScriptError = "ssReaderJSError"
/// 固定版式渲染就绪事件
case fixedLayoutReady = "ssReaderFixedLayoutReady"
}
/// JavaScript 桥接工具集,负责生成注入脚本和执行脚本
enum RDEPUBJavaScriptBridge {
/// 所有需要注册的 JS 消息名称列表,用于 WKUserContentController 注册
static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
}
/// 获取注入到 WebView 的桥接用户脚本(在 document end 注入)
/// 将消息类型占位符替换为实际值,使 JS 端能通过 window.webkit.messageHandlers 发送消息
static var userScript: String {
[
RDEPUBAssetRepository.string(for: .rangyCoreScript),
RDEPUBAssetRepository.string(for: .rangySerializerScript),
RDEPUBAssetRepository.string(for: .cssInjectorScript),
RDEPUBAssetRepository.string(for: .weReadAPIScript),
RDEPUBAssetRepository.string(
for: .bridgeScript,
replacements: [
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
]
)
].joined(separator: "\n\n")
}
/// 生成重排模式下的分页渲染脚本
/// 注入 CSS 分页样式、设置页码参数、高亮和目标位置,最后触发进度汇报
/// - Parameter request: 重排渲染请求,包含展示样式、高亮、目标位置等信息
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width)
let searchScript = applySearchScript(for: request.searchPresentation)
return """
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.applySharedTheme({
backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)),
textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)),
includeFrames: false
});
window.WeReadApi.applyPagination(`\(style)`);
window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
window.WeReadApi.clearHighlights();
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
} else {
window.WeReadApi.scrollToPage(\(request.pageIndex));
}
window.WeReadApi.reportProgression();
return true;
})();
\(searchScript)
"""
}
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
"""
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.applySharedTheme({
backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)),
textColor: null,
includeFrames: true
});
return true;
})();
"""
}
/// 生成搜索高亮脚本
/// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮,
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
/// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等)
/// - Returns: 搜索高亮脚本字符串
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
(function() {
if (!window.WeReadApi) { return false; }
window.WeReadApi.setSearchPresentation(\(payload));
return true;
})();
"""
}
static func resolveDecorationsScript() -> String {
"""
(function() {
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
return window.WeReadApi.resolveDecorations();
})();
"""
}
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
}
/// 将高亮数组转换为 JS 可用的字典数组
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
return [
"id": highlight.id,
"rangeInfo": rangeInfo,
"color": highlight.color,
"style": highlight.style.rawValue
]
}
}
/// 将目标位置转换为 JS 可用的字典(含 progression、fragment
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil }
return [
"progression": location.progression,
"lastProgression": location.lastProgression ?? location.progression,
"fragment": location.fragment as Any
]
}
/// 将搜索展示信息转换为 JS 可用的字典
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil }
return [
"keyword": presentation.keyword,
"resources": presentation.resources.map { resource in
[
"href": resource.href,
"matchCount": resource.matchCount,
"activeLocalMatchIndex": resource.activeLocalMatchIndex as Any
]
}
]
}
/// 将任意对象序列化为 JSON 字符串,失败时返回 fallback
private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
let string = String(data: data, encoding: .utf8) else {
return fallback
}
return string
}
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
.replacingOccurrences(of: "[", with: "")
.replacingOccurrences(of: "]", with: "")
}
}