// 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) """ } /// 生成固定版式模式下的主题应用脚本 /// 仅应用共享主题(背景色),不涉及分页逻辑(固定版式由 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: "") } }