- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级) - 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行) - 根目录翻页容器文件移入 ReaderView/ 目录 - Resources/ 移入 EPUBCore/Resources/(与使用者归属一致) - RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖) - RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层) - 更新 podspec 资源路径
297 lines
14 KiB
Swift
297 lines
14 KiB
Swift
// 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
|
||
}
|
||
}
|