- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
147 lines
5.4 KiB
Swift
147 lines
5.4 KiB
Swift
// RDEPUBWebView+Search.swift
|
||
// RDEPUBWebView 的搜索高亮扩展
|
||
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
||
|
||
import UIKit
|
||
import WebKit
|
||
|
||
extension RDEPUBWebView {
|
||
private var normalSearchOverlayColor: UIColor {
|
||
UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||
}
|
||
|
||
private var activeSearchOverlayColor: UIColor {
|
||
UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||
}
|
||
|
||
/// 如果当前渲染请求包含搜索信息,注入搜索高亮脚本到 WebView
|
||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||
guard let webView else {
|
||
completion?()
|
||
return
|
||
}
|
||
|
||
let presentation: RDEPUBSearchPresentation?
|
||
switch currentRenderRequest {
|
||
case .reflowable(let request):
|
||
presentation = request.searchPresentation
|
||
case .fixed(let request):
|
||
presentation = request.searchPresentation
|
||
case .none:
|
||
presentation = nil
|
||
}
|
||
|
||
let script = RDEPUBJavaScriptBridge.applySearchScript(for: presentation)
|
||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
||
guard let self else {
|
||
completion?()
|
||
return
|
||
}
|
||
if let error {
|
||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||
}
|
||
self.refreshNativeDecorationsIfNeeded(completion: completion)
|
||
}
|
||
}
|
||
|
||
func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||
guard let webView else {
|
||
onDecorationsResolved?([])
|
||
completion?()
|
||
return
|
||
}
|
||
|
||
webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in
|
||
guard let self else {
|
||
completion?()
|
||
return
|
||
}
|
||
if let error {
|
||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||
self.onDecorationsResolved?([])
|
||
completion?()
|
||
return
|
||
}
|
||
|
||
let decorations = self.nativeDecorations(from: result)
|
||
self.onDecorationsResolved?(decorations)
|
||
completion?()
|
||
}
|
||
}
|
||
|
||
private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] {
|
||
guard let payload = result as? [String: Any] else { return [] }
|
||
|
||
let highlightDecorations = decorationList(
|
||
from: payload["highlights"] as? [[String: Any]],
|
||
defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||
)
|
||
let searchDecorations = decorationList(
|
||
from: payload["search"] as? [[String: Any]],
|
||
defaultColor: normalSearchOverlayColor
|
||
)
|
||
return highlightDecorations + searchDecorations
|
||
}
|
||
|
||
private func decorationList(
|
||
from items: [[String: Any]]?,
|
||
defaultColor: UIColor
|
||
) -> [RDEPUBTextOverlayDecoration] {
|
||
guard let items else { return [] }
|
||
|
||
return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in
|
||
guard let kindString = item["kind"] as? String,
|
||
let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString),
|
||
let rectPayloads = item["rects"] as? [[String: Any]] else {
|
||
return nil
|
||
}
|
||
|
||
let rects = rectPayloads.compactMap { rectPayload -> CGRect? in
|
||
guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue,
|
||
let y = (rectPayload["y"] as? NSNumber)?.doubleValue,
|
||
let width = (rectPayload["width"] as? NSNumber)?.doubleValue,
|
||
let height = (rectPayload["height"] as? NSNumber)?.doubleValue else {
|
||
return nil
|
||
}
|
||
return CGRect(x: x, y: y, width: width, height: height)
|
||
}
|
||
guard !rects.isEmpty else { return nil }
|
||
|
||
let color: UIColor
|
||
switch kind {
|
||
case .underline:
|
||
let hex = item["color"] as? String ?? "#F8E16C"
|
||
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||
case .activeSearch:
|
||
color = activeSearchOverlayColor
|
||
case .search:
|
||
color = normalSearchOverlayColor
|
||
case .highlight:
|
||
let hex = item["color"] as? String ?? "#F8E16C"
|
||
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||
case .selection, .locate:
|
||
color = defaultColor
|
||
}
|
||
|
||
return RDEPUBTextOverlayDecoration(
|
||
kind: kind,
|
||
absoluteRange: NSRange(location: index, length: 1),
|
||
rects: rects,
|
||
color: color
|
||
)
|
||
}
|
||
}
|
||
|
||
private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? {
|
||
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
value = value.replacingOccurrences(of: "#", with: "")
|
||
guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil }
|
||
return UIColor(
|
||
red: CGFloat((hexValue >> 16) & 0xFF) / 255,
|
||
green: CGFloat((hexValue >> 8) & 0xFF) / 255,
|
||
blue: CGFloat(hexValue & 0xFF) / 255,
|
||
alpha: alpha
|
||
)
|
||
}
|
||
}
|