// 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: .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 ] ) } /// 生成重排模式下的分页渲染脚本 /// 注入 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.RDReaderBridge) { return false; } window.RDReaderBridge.applyPagination(`\(style)`); window.RDReaderBridge.setPageMetrics(\(request.totalPagesInChapter), \(pageStride)); window.RDReaderBridge.clearHighlights(); window.RDReaderBridge.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]"))); if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) { window.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex)); } else { window.RDReaderBridge.scrollToPage(\(request.pageIndex)); } window.RDReaderBridge.reportProgression(); return true; })(); \(searchScript) """ } /// 生成搜索高亮脚本 /// 在当前文档和所有 iframe 中查找关键词并用 span 标记高亮, /// 活跃匹配项使用特殊样式并自动滚动到视图中央 /// - Parameter presentation: 搜索展示信息(关键词、各资源的匹配数量等) /// - Returns: 搜索高亮脚本字符串 static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String { let payload = jsonString(from: searchPayload(presentation), fallback: "null") return """ (function() { var payload = \(payload); function normalizeHref(rawHref) { if (!rawHref) { return ''; } var value = String(rawHref); value = value.replace(/^.*?:\\/\\/[^/]+\\//, ''); value = value.split('#')[0]; value = value.replace(/^\\/+/, ''); try { value = decodeURIComponent(value); } catch (error) { } return value; } function ensureStyle(doc) { if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; } var style = doc.createElement('style'); style.id = 'ss-reader-search-style'; style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}'; doc.head.appendChild(style); } function clearMarks(doc) { if (!doc || !doc.body) { return; } var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active'); marks.forEach(function(mark) { var textNode = doc.createTextNode(mark.textContent || ''); if (mark.parentNode) { mark.parentNode.replaceChild(textNode, mark); } }); doc.body.normalize(); } function searchableDocuments(rootDoc) { var items = []; if (!rootDoc) { return items; } items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) }); var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : []; frames.forEach(function(frame) { try { if (frame.contentDocument) { items.push({ doc: frame.contentDocument, href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href) }); } } catch (error) { } }); return items; } function textNodes(doc) { if (!doc || !doc.body) { return []; } var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, { acceptNode: function(node) { if (!node || !node.nodeValue || !node.nodeValue.trim()) { return NodeFilter.FILTER_REJECT; } var parent = node.parentNode; if (!parent || !parent.nodeName) { return NodeFilter.FILTER_REJECT; } var name = parent.nodeName.toLowerCase(); if (['script', 'style', 'noscript'].indexOf(name) >= 0) { return NodeFilter.FILTER_REJECT; } if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }); var nodes = []; while (walker.nextNode()) { nodes.push(walker.currentNode); } return nodes; } function applyToDocument(doc, resourceState, keyword) { clearMarks(doc); ensureStyle(doc); if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) { return; } var lowerKeyword = String(keyword).toLowerCase(); var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null; var currentLocalIndex = 0; var activeElement = null; textNodes(doc).forEach(function(node) { var source = node.nodeValue || ''; var lowerSource = source.toLowerCase(); var cursor = 0; var replaced = false; var fragment = doc.createDocumentFragment(); while (cursor < source.length) { var foundIndex = lowerSource.indexOf(lowerKeyword, cursor); if (foundIndex < 0) { break; } replaced = true; if (foundIndex > cursor) { fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex))); } var mark = doc.createElement('span'); mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark'; mark.textContent = source.slice(foundIndex, foundIndex + keyword.length); fragment.appendChild(mark); if (currentLocalIndex === activeIndex) { activeElement = mark; } currentLocalIndex += 1; cursor = foundIndex + keyword.length; } if (!replaced) { return; } if (cursor < source.length) { fragment.appendChild(doc.createTextNode(source.slice(cursor))); } if (node.parentNode) { node.parentNode.replaceChild(fragment, node); } }); if (activeElement && activeElement.scrollIntoView) { activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); } } var docs = searchableDocuments(document); var states = new Map(); if (payload && Array.isArray(payload.resources)) { payload.resources.forEach(function(resource) { states.set(normalizeHref(resource.href), resource); }); } docs.forEach(function(item) { applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : ''); }); return true; })(); """ } /// 转义字符串中的反斜杠和反引号,避免 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 } }