feat(epub): complete wxread parity scope
This commit is contained in:
@@ -7,6 +7,14 @@ import Foundation
|
||||
|
||||
/// EPUB 内置资源枚举,定义桥接脚本和固定版式模板的文件名及扩展名
|
||||
enum RDEPUBAsset: String {
|
||||
/// Rangy core 等价桥接脚本,负责提供 Web range/selection 基础能力
|
||||
case rangyCoreScript = "rangy-core"
|
||||
/// Rangy serializer 等价桥接脚本,负责 DOM range 序列化/反序列化
|
||||
case rangySerializerScript = "rangy-serializer"
|
||||
/// WXRead 对齐的动态 CSS 注入脚本
|
||||
case cssInjectorScript = "cssInjector"
|
||||
/// WXRead 对齐的 WeReadApi JS-Native 桥接脚本
|
||||
case weReadAPIScript = "WeReadApi"
|
||||
/// JS 桥接脚本(epub-bridge.js),注入到 WebView 中实现原生-JS 通信
|
||||
case bridgeScript = "epub-bridge"
|
||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||
@@ -23,7 +31,7 @@ enum RDEPUBAsset: String {
|
||||
/// 对应的文件扩展名
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .bridgeScript:
|
||||
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
|
||||
return "js"
|
||||
case .fixedLayoutTemplate:
|
||||
return "html"
|
||||
|
||||
@@ -31,17 +31,23 @@ enum RDEPUBJavaScriptBridge {
|
||||
/// 获取注入到 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
|
||||
]
|
||||
)
|
||||
[
|
||||
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")
|
||||
}
|
||||
|
||||
/// 生成重排模式下的分页渲染脚本
|
||||
@@ -55,23 +61,42 @@ enum RDEPUBJavaScriptBridge {
|
||||
|
||||
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 (!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.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
window.WeReadApi.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
} else {
|
||||
window.RDReaderBridge.scrollToPage(\(request.pageIndex));
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
window.RDReaderBridge.reportProgression();
|
||||
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 标记高亮,
|
||||
/// 活跃匹配项使用特殊样式并自动滚动到视图中央
|
||||
@@ -81,8 +106,8 @@ enum RDEPUBJavaScriptBridge {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return false; }
|
||||
window.RDReaderBridge.setSearchPresentation(\(payload));
|
||||
if (!window.WeReadApi) { return false; }
|
||||
window.WeReadApi.setSearchPresentation(\(payload));
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
@@ -91,8 +116,8 @@ enum RDEPUBJavaScriptBridge {
|
||||
static func resolveDecorationsScript() -> String {
|
||||
"""
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return { highlights: [], search: [] }; }
|
||||
return window.RDReaderBridge.resolveDecorations();
|
||||
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
|
||||
return window.WeReadApi.resolveDecorations();
|
||||
})();
|
||||
"""
|
||||
}
|
||||
@@ -152,4 +177,11 @@ enum RDEPUBJavaScriptBridge {
|
||||
}
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,18 @@ public struct RDEPUBPreferences: Equatable {
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定版式双页展开模式(never/always/automatic)
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
/// 多栏排版栏数
|
||||
public var numberOfColumns: Int
|
||||
/// 多栏排版栏间距
|
||||
public var columnGap: CGFloat
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
reflowableContentInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil,
|
||||
fixedBackgroundColor: String? = nil,
|
||||
@@ -41,6 +47,8 @@ public struct RDEPUBPreferences: Equatable {
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
self.fixedBackgroundColor = fixedBackgroundColor
|
||||
@@ -55,6 +63,8 @@ public struct RDEPUBPreferences: Equatable {
|
||||
contentInsets: reflowableContentInsets,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
numberOfColumns: numberOfColumns,
|
||||
columnGap: columnGap,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
@@ -99,4 +109,4 @@ public struct RDEPUBPreferences: Equatable {
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,13 @@ public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 统一标注类型,对标 WXRead 的 WRBookmark 语义分层。
|
||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
||||
case bookmark
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
/// 标注菜单操作类型,用于用户长按选中文本后的操作选项
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
@@ -396,6 +403,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(highlight: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 书签模型,记录用户标记的阅读位置
|
||||
@@ -433,6 +444,108 @@ public struct RDEPUBBookmark: Codable, Equatable {
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
public var annotation: RDEPUBAnnotation {
|
||||
RDEPUBAnnotation(bookmark: self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一标注模型,对标 WXRead 的 WRBookmark。
|
||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var kind: RDEPUBAnnotationKind
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String?
|
||||
public var rangeInfo: String?
|
||||
public var color: String?
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
kind: RDEPUBAnnotationKind,
|
||||
location: RDEPUBLocation,
|
||||
text: String? = nil,
|
||||
rangeInfo: String? = nil,
|
||||
color: String? = nil,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.kind = kind
|
||||
self.location = location
|
||||
self.text = text?.nilIfEmpty
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.color = color?.nilIfEmpty
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public init(highlight: RDEPUBHighlight) {
|
||||
self.init(
|
||||
id: highlight.id,
|
||||
bookIdentifier: highlight.bookIdentifier,
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
location: highlight.location,
|
||||
text: highlight.text,
|
||||
rangeInfo: highlight.rangeInfo,
|
||||
color: highlight.color,
|
||||
chapterTitle: nil,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public init(bookmark: RDEPUBBookmark) {
|
||||
self.init(
|
||||
id: bookmark.id,
|
||||
bookIdentifier: bookmark.bookIdentifier,
|
||||
kind: .bookmark,
|
||||
location: bookmark.location,
|
||||
text: nil,
|
||||
rangeInfo: nil,
|
||||
color: nil,
|
||||
chapterTitle: bookmark.chapterTitle,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var bookmark: RDEPUBBookmark? {
|
||||
guard kind == .bookmark else { return nil }
|
||||
return RDEPUBBookmark(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
chapterTitle: chapterTitle,
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
|
||||
public var highlight: RDEPUBHighlight? {
|
||||
guard kind == .highlight || kind == .underline,
|
||||
let text else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBHighlight(
|
||||
id: id,
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo,
|
||||
style: kind == .underline ? .underline : .highlight,
|
||||
color: color ?? "#F8E16C",
|
||||
note: note,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 章节信息模型,描述单个章节的元数据
|
||||
|
||||
@@ -41,6 +41,10 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数(1.0 为默认行距)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 栏数,对标 WXRead 的多栏分页配置
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,对标 WXRead 的 columnGap
|
||||
public var columnGap: CGFloat
|
||||
/// 主题背景色(CSS 颜色值,如 "#FFFFFF")
|
||||
public var themeBackgroundColor: String?
|
||||
/// 主题文字色(CSS 颜色值,如 "#000000")
|
||||
@@ -51,6 +55,8 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
contentInsets: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) {
|
||||
@@ -58,6 +64,8 @@ public struct RDEPUBPresentationStyle: Equatable {
|
||||
self.contentInsets = contentInsets
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
}
|
||||
@@ -170,4 +178,4 @@ public enum RDEPUBRenderRequest: Equatable {
|
||||
return request.spread.primaryResource.href
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||||
|
||||
return """
|
||||
html {
|
||||
@@ -70,6 +71,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
\(columnCountCSS)
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
@@ -109,6 +111,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
||||
|
||||
return """
|
||||
html {
|
||||
@@ -132,6 +135,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
\(columnCountCSS)
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
@@ -187,6 +191,7 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
viewportHeight: String,
|
||||
contentWidth: String,
|
||||
columnGap: String,
|
||||
numberOfColumns: Int,
|
||||
paddingTop: String,
|
||||
paddingRight: String,
|
||||
paddingBottom: String,
|
||||
@@ -197,14 +202,18 @@ public enum RDEPUBStyleSheetBuilder {
|
||||
let viewportWidth = max(1, presentation.viewportSize.width)
|
||||
let viewportHeight = max(1, presentation.viewportSize.height)
|
||||
let contentInsets = presentation.contentInsets
|
||||
let contentWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||||
let columnGap = max(0, contentInsets.left + contentInsets.right)
|
||||
let numberOfColumns = max(1, presentation.numberOfColumns)
|
||||
let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||||
let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap)
|
||||
let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns))
|
||||
let columnGap = max(0, presentation.columnGap)
|
||||
|
||||
return (
|
||||
viewportWidth: String(format: "%.3f", viewportWidth),
|
||||
viewportHeight: String(format: "%.3f", viewportHeight),
|
||||
contentWidth: String(format: "%.3f", contentWidth),
|
||||
columnGap: String(format: "%.3f", columnGap),
|
||||
numberOfColumns: numberOfColumns,
|
||||
paddingTop: String(format: "%.3f", contentInsets.top),
|
||||
paddingRight: String(format: "%.3f", contentInsets.right),
|
||||
paddingBottom: String(format: "%.3f", contentInsets.bottom),
|
||||
|
||||
@@ -91,8 +91,10 @@ extension RDEPUBWebView {
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
||||
self?.applySearchDecorationsIfNeeded {
|
||||
self?.rendered()
|
||||
self?.applyFixedPresentationIfNeeded {
|
||||
self?.refreshNativeDecorationsIfNeeded {
|
||||
self?.rendered()
|
||||
}
|
||||
}
|
||||
}
|
||||
fixedLayoutReadyWorkItem = workItem
|
||||
@@ -104,4 +106,25 @@ extension RDEPUBWebView {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
}
|
||||
|
||||
func applyFixedPresentationIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView,
|
||||
let currentRenderRequest,
|
||||
case .fixed(let request) = currentRenderRequest else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
let script = RDEPUBJavaScriptBridge.applyFixedPresentationScript(for: request)
|
||||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
||||
guard let self else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||
}
|
||||
self.applySearchDecorationsIfNeeded(completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ extension RDEPUBWebView {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
||||
let fragment = body["fragment"] as? String
|
||||
return RDEPUBLocation(href: currentHref, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
||||
let href = body["href"] as? String ?? currentHref
|
||||
return RDEPUBLocation(href: href, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,11 +109,13 @@ extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue:
|
||||
delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)")
|
||||
case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue:
|
||||
applySearchDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
applyFixedPresentationIfNeeded {
|
||||
self.refreshNativeDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ extension RDEPUBWebView {
|
||||
String(format: "%.2f", presentation.contentInsets.right),
|
||||
String(format: "%.2f", presentation.fontSize),
|
||||
String(format: "%.2f", presentation.lineHeightMultiple),
|
||||
String(presentation.numberOfColumns),
|
||||
String(format: "%.2f", presentation.columnGap),
|
||||
presentation.themeBackgroundColor ?? "",
|
||||
presentation.themeTextColor ?? "",
|
||||
targetSignature,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
(function() {
|
||||
if (window.WeReadApi) { return; }
|
||||
|
||||
function bridge() {
|
||||
return window.RDReaderBridge || null;
|
||||
}
|
||||
|
||||
function sharedThemeCSS(theme) {
|
||||
if (!theme) { return ''; }
|
||||
var backgroundColor = theme.backgroundColor || 'transparent';
|
||||
var textColor = theme.textColor || 'inherit';
|
||||
return [
|
||||
':root {',
|
||||
' color-scheme: light;',
|
||||
'}',
|
||||
'html, body {',
|
||||
' background: ' + backgroundColor + ' !important;',
|
||||
' color: ' + textColor + ' !important;',
|
||||
'}',
|
||||
'a {',
|
||||
' -webkit-tap-highlight-color: transparent;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
window.WeReadApi = {
|
||||
applySharedTheme: function(theme) {
|
||||
var css = sharedThemeCSS(theme);
|
||||
if (window.RDInjectedCSS) {
|
||||
window.RDInjectedCSS.setStyle('ss-reader-theme', css, {
|
||||
includeFrames: !!(theme && theme.includeFrames)
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
applyPagination: function(styleText) {
|
||||
return !!(bridge() && bridge().applyPagination(styleText));
|
||||
},
|
||||
setPageMetrics: function(pageCount, pageStride) {
|
||||
return !!(bridge() && bridge().setPageMetrics(pageCount, pageStride));
|
||||
},
|
||||
scrollToPage: function(pageIndex) {
|
||||
return !!(bridge() && bridge().scrollToPage(pageIndex));
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
return !!(bridge() && bridge().scrollToLocation(location, fallbackPageIndex));
|
||||
},
|
||||
setHighlights: function(items) {
|
||||
return !!(bridge() && bridge().setHighlights(items));
|
||||
},
|
||||
clearHighlights: function() {
|
||||
return !!(bridge() && bridge().clearHighlights());
|
||||
},
|
||||
setSearchPresentation: function(payload) {
|
||||
return !!(bridge() && bridge().setSearchPresentation(payload));
|
||||
},
|
||||
resolveDecorations: function() {
|
||||
return bridge() ? bridge().resolveDecorations() : { highlights: [], search: [] };
|
||||
},
|
||||
reportProgression: function(fragment) {
|
||||
return !!(bridge() && bridge().reportProgression(fragment));
|
||||
},
|
||||
selectionPayload: function() {
|
||||
return bridge() ? bridge().selectionPayload() : null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
(function() {
|
||||
if (window.RDInjectedCSS) { return; }
|
||||
|
||||
function applyStyle(doc, identifier, cssText) {
|
||||
if (!doc || !doc.head) { return; }
|
||||
var style = doc.getElementById(identifier);
|
||||
if (!style) {
|
||||
style = doc.createElement('style');
|
||||
style.id = identifier;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
style.textContent = cssText || '';
|
||||
}
|
||||
|
||||
function walkDocuments(rootDoc, includeFrames, visitor) {
|
||||
if (!rootDoc) { return; }
|
||||
visitor(rootDoc);
|
||||
if (!includeFrames || !rootDoc.querySelectorAll) { return; }
|
||||
Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
walkDocuments(frame.contentDocument, includeFrames, visitor);
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.RDInjectedCSS = {
|
||||
setStyle: function(identifier, cssText, options) {
|
||||
var includeFrames = !!(options && options.includeFrames);
|
||||
walkDocuments(document, includeFrames, function(doc) {
|
||||
applyStyle(doc, identifier, cssText);
|
||||
});
|
||||
},
|
||||
removeStyle: function(identifier, options) {
|
||||
var includeFrames = !!(options && options.includeFrames);
|
||||
walkDocuments(document, includeFrames, function(doc) {
|
||||
var style = doc.getElementById(identifier);
|
||||
if (style && style.parentNode) {
|
||||
style.parentNode.removeChild(style);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -7,48 +7,17 @@
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
function nodePath(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
while (current && current !== document) {
|
||||
var parent = current.parentNode;
|
||||
if (!parent) { break; }
|
||||
var index = Array.prototype.indexOf.call(parent.childNodes, current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
function nodeFromPath(path) {
|
||||
var current = document;
|
||||
for (var i = 0; i < path.length; i += 1) {
|
||||
if (!current || !current.childNodes || current.childNodes.length <= path[i]) { return null; }
|
||||
current = current.childNodes[path[i]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function serializeRange(range) {
|
||||
return JSON.stringify({
|
||||
kind: 'dom-range',
|
||||
startPath: nodePath(range.startContainer),
|
||||
startOffset: range.startOffset,
|
||||
endPath: nodePath(range.endContainer),
|
||||
endOffset: range.endOffset
|
||||
});
|
||||
if (window.rangy && typeof window.rangy.serializeRange === 'function') {
|
||||
return window.rangy.serializeRange(range, range && range.startContainer && range.startContainer.ownerDocument);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function rangeFromInfo(rangeInfo) {
|
||||
try {
|
||||
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
|
||||
var startNode = nodeFromPath(payload.startPath || []);
|
||||
var endNode = nodeFromPath(payload.endPath || []);
|
||||
if (!startNode || !endNode) { return null; }
|
||||
var range = document.createRange();
|
||||
range.setStart(startNode, payload.startOffset || 0);
|
||||
range.setEnd(endNode, payload.endOffset || 0);
|
||||
return range;
|
||||
} catch (error) {
|
||||
return null;
|
||||
if (window.rangy && typeof window.rangy.deserializeRange === 'function') {
|
||||
return window.rangy.deserializeRange(rangeInfo, document);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var visiblePageIndex = 0;
|
||||
var configuredPageCount = 1;
|
||||
@@ -153,6 +122,22 @@
|
||||
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
|
||||
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
|
||||
}
|
||||
function fragmentTarget(fragment) {
|
||||
if (!fragment) { return null; }
|
||||
var contexts = documentContexts(document, 0, 0);
|
||||
for (var index = 0; index < contexts.length; index += 1) {
|
||||
var context = contexts[index];
|
||||
var target = context.doc.getElementById(fragment)
|
||||
|| context.doc.querySelector('[name="' + fragment.replace(/"/g, '\\"') + '"]');
|
||||
if (target) {
|
||||
return {
|
||||
context: context,
|
||||
target: target
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var storedHighlights = [];
|
||||
var storedSearchPresentation = null;
|
||||
function normalizeHref(rawHref) {
|
||||
@@ -228,6 +213,36 @@
|
||||
});
|
||||
return items;
|
||||
}
|
||||
var hookedDocuments = new WeakSet();
|
||||
var hookedFrames = new WeakSet();
|
||||
function selectionForDocument(doc) {
|
||||
if (!doc) { return null; }
|
||||
try {
|
||||
var view = doc.defaultView || window;
|
||||
return view.getSelection ? view.getSelection() : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function currentSelectionContext() {
|
||||
var contexts = documentContexts(document, 0, 0);
|
||||
for (var index = 0; index < contexts.length; index += 1) {
|
||||
var context = contexts[index];
|
||||
var selection = selectionForDocument(context.doc);
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
continue;
|
||||
}
|
||||
var text = selection.toString ? selection.toString() : '';
|
||||
if (!text || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
context: context,
|
||||
selection: selection
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function rectPayloadForRange(range, offsetX, offsetY) {
|
||||
if (!range || range.collapsed) { return []; }
|
||||
var rects = [];
|
||||
@@ -344,17 +359,18 @@
|
||||
};
|
||||
}
|
||||
function selectionPayload() {
|
||||
var selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
var payloadContext = currentSelectionContext();
|
||||
if (!payloadContext) {
|
||||
return null;
|
||||
}
|
||||
var range = selection.getRangeAt(0);
|
||||
var text = selection.toString();
|
||||
var range = payloadContext.selection.getRangeAt(0);
|
||||
var text = payloadContext.selection.toString();
|
||||
if (!text || !text.trim()) { return null; }
|
||||
var pageCount = totalPageCount();
|
||||
var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1);
|
||||
var lastProgression = progression;
|
||||
return {
|
||||
href: payloadContext.context.href || null,
|
||||
text: text,
|
||||
rangeInfo: serializeRange(range),
|
||||
progression: progression,
|
||||
@@ -362,13 +378,54 @@
|
||||
};
|
||||
}
|
||||
var selectionTimer = null;
|
||||
document.addEventListener('selectionchange', function() {
|
||||
function notifySelectionChange() {
|
||||
clearTimeout(selectionTimer);
|
||||
selectionTimer = setTimeout(function() {
|
||||
var payload = selectionPayload();
|
||||
window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload);
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
function handleDocumentClick(event) {
|
||||
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (!anchor) { return; }
|
||||
var href = anchor.getAttribute('href');
|
||||
if (!href) { return; }
|
||||
if (/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href });
|
||||
}
|
||||
function installDocumentHooks(doc) {
|
||||
if (!doc || hookedDocuments.has(doc)) { return; }
|
||||
hookedDocuments.add(doc);
|
||||
doc.addEventListener('selectionchange', notifySelectionChange);
|
||||
doc.addEventListener('click', handleDocumentClick, true);
|
||||
}
|
||||
function installFrameHooks(rootDoc) {
|
||||
if (!rootDoc || !rootDoc.querySelectorAll) { return; }
|
||||
Array.prototype.forEach.call(rootDoc.querySelectorAll('iframe'), function(frame) {
|
||||
if (hookedFrames.has(frame)) { return; }
|
||||
hookedFrames.add(frame);
|
||||
var hookFrameDocument = function() {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
installDocumentHooks(frame.contentDocument);
|
||||
installFrameHooks(frame.contentDocument);
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
frame.addEventListener('load', function() {
|
||||
hookFrameDocument();
|
||||
scheduleRelayout();
|
||||
}, { passive: true });
|
||||
hookFrameDocument();
|
||||
});
|
||||
}
|
||||
installDocumentHooks(document);
|
||||
installFrameHooks(document);
|
||||
window.addEventListener('resize', function() {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
setVisiblePageIndex(visiblePageIndex);
|
||||
@@ -384,30 +441,22 @@
|
||||
node.addEventListener('error', scheduleRelayout, { passive: true });
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(event) {
|
||||
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
|
||||
if (!anchor) { return; }
|
||||
var href = anchor.getAttribute('href');
|
||||
if (!href) { return; }
|
||||
if (/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href });
|
||||
}, true);
|
||||
window.RDReaderBridge = {
|
||||
applyPagination: function(styleText) {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
var style = document.getElementById('ss-reader-pagination');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'ss-reader-pagination';
|
||||
document.head.appendChild(style);
|
||||
if (window.RDInjectedCSS) {
|
||||
window.RDInjectedCSS.setStyle('ss-reader-pagination', styleText, { includeFrames: false });
|
||||
} else {
|
||||
var style = document.getElementById('ss-reader-pagination');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'ss-reader-pagination';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = styleText;
|
||||
}
|
||||
style.textContent = styleText;
|
||||
ensurePaginationStructure();
|
||||
installFrameHooks(document);
|
||||
},
|
||||
setPageMetrics: function(pageCount, pageStride) {
|
||||
if (isFixedLayoutDocument()) { return; }
|
||||
@@ -423,9 +472,15 @@
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
if (location && location.fragment) {
|
||||
var target = document.getElementById(location.fragment) || document.querySelector('[name="' + location.fragment.replace(/"/g, '\\"') + '"]');
|
||||
if (target) {
|
||||
setVisiblePageIndex(pageIndexForElement(target));
|
||||
var resolvedTarget = fragmentTarget(location.fragment);
|
||||
if (resolvedTarget) {
|
||||
if (isFixedLayoutDocument()) {
|
||||
reportProgression(location.fragment);
|
||||
return;
|
||||
}
|
||||
var rect = resolvedTarget.target.getBoundingClientRect();
|
||||
var absoluteLeft = Math.max(0, rect.left + resolvedTarget.context.offsetX + currentPageOffset());
|
||||
setVisiblePageIndex(Math.max(0, Math.floor(absoluteLeft / effectivePageStride())));
|
||||
reportProgression(location.fragment);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
(function() {
|
||||
if (window.rangy) { return; }
|
||||
|
||||
window.rangy = {
|
||||
initialized: true,
|
||||
init: function() {
|
||||
return window.rangy;
|
||||
},
|
||||
createRange: function(doc) {
|
||||
return (doc || document).createRange();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
(function() {
|
||||
if (!window.rangy) {
|
||||
window.rangy = {};
|
||||
}
|
||||
if (window.rangy.serializeRange && window.rangy.deserializeRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 nodePath(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
while (current && current !== current.ownerDocument) {
|
||||
var parent = current.parentNode;
|
||||
if (!parent) { break; }
|
||||
var index = Array.prototype.indexOf.call(parent.childNodes, current);
|
||||
path.unshift(index);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function nodeFromPath(doc, path) {
|
||||
var current = doc;
|
||||
for (var index = 0; index < path.length; index += 1) {
|
||||
if (!current || !current.childNodes || current.childNodes.length <= path[index]) {
|
||||
return null;
|
||||
}
|
||||
current = current.childNodes[path[index]];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function documentContexts(rootDoc) {
|
||||
var items = [];
|
||||
if (!rootDoc) { return items; }
|
||||
items.push(rootDoc);
|
||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||
Array.prototype.forEach.call(frames, function(frame) {
|
||||
try {
|
||||
if (!frame.contentDocument) { return; }
|
||||
documentContexts(frame.contentDocument).forEach(function(item) {
|
||||
items.push(item);
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
window.rangy.serializeRange = function(range, doc) {
|
||||
var ownerDocument = doc || (range && range.startContainer && range.startContainer.ownerDocument) || document;
|
||||
return JSON.stringify({
|
||||
kind: 'dom-range',
|
||||
documentHref: normalizeHref(ownerDocument.location && ownerDocument.location.href),
|
||||
startPath: nodePath(range.startContainer),
|
||||
startOffset: range.startOffset,
|
||||
endPath: nodePath(range.endContainer),
|
||||
endOffset: range.endOffset
|
||||
});
|
||||
};
|
||||
|
||||
window.rangy.deserializeRange = function(rangeInfo, rootDoc) {
|
||||
try {
|
||||
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
|
||||
var documentHref = normalizeHref(payload.documentHref);
|
||||
var resolvedDocument = (documentContexts(rootDoc || document).find(function(candidate) {
|
||||
return normalizeHref(candidate.location && candidate.location.href) === documentHref;
|
||||
})) || (rootDoc || document);
|
||||
var startNode = nodeFromPath(resolvedDocument, payload.startPath || []);
|
||||
var endNode = nodeFromPath(resolvedDocument, payload.endPath || []);
|
||||
if (!startNode || !endNode) { return null; }
|
||||
var range = resolvedDocument.createRange();
|
||||
range.setStart(startNode, payload.startOffset || 0);
|
||||
range.setEnd(endNode, payload.endOffset || 0);
|
||||
return range;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user