Files
ReadViewSDK/Sources/RDReaderView/EPUBCore/RDEPUBJavaScriptBridge.swift
T
shenandshen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

201 lines
9.3 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),
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
);
} else {
window.WeReadApi.scrollToPage(\(request.pageIndex));
}
window.WeReadApi.reportProgression();
return true;
})();
\(searchScript)
"""
}
/// 生成固定版式模式下的主题应用脚本
/// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 HTML 自身布局决定)
/// - Parameter request: 固定版式渲染请求,包含背景色等信息
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串
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;
})();
"""
}
/// 生成获取当前装饰数据的脚本
/// 从 WebView 中读取高亮和搜索高亮的 DOM 装饰信息,用于同步原生标注状态
/// - Returns: 可直接通过 evaluateJavaScript 执行的脚本字符串,返回包含 highlights 和 search 的对象
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
}
/// 将 Swift 字符串转换为 JavaScript 字符串字面量
/// nil 值转为 "null",非空值序列化为 JSON 字符串后去除外层方括号
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: "")
}
}