feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
This commit is contained in:
@@ -72,7 +72,11 @@ enum RDEPUBJavaScriptBridge {
|
||||
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));
|
||||
window.WeReadApi.scrollToLocation(
|
||||
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
|
||||
\(request.pageIndex),
|
||||
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
|
||||
);
|
||||
} else {
|
||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
case missingManifestItem(idref: String)
|
||||
/// 构建完成的 spine 为空(无可阅读的内容)
|
||||
case emptySpine
|
||||
/// 归档条目路径包含路径穿越或非法字符
|
||||
case invalidArchiveEntryPath(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -229,6 +231,8 @@ public enum RDEPUBParserError: LocalizedError {
|
||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||
case .emptySpine:
|
||||
return "OPF spine 为空"
|
||||
case .invalidArchiveEntryPath(let path):
|
||||
return "归档条目路径非法: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,7 @@ public final class RDEPUBPaginator: NSObject {
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.isUserInteractionEnabled = false
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
return webView
|
||||
@@ -297,6 +295,9 @@ public final class RDEPUBPaginator: NSObject {
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
/// 各轮测量记录值(用于稳定性日志)
|
||||
private var measurementPassValues: [Int] = []
|
||||
|
||||
/// 调度多轮测量:0ms/80ms/180ms 三轮延迟,取最大值以应对布局抖动
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
@@ -306,8 +307,18 @@ public final class RDEPUBPaginator: NSObject {
|
||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||
return
|
||||
}
|
||||
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
|
||||
let finalValue = max(1, pendingMeasurementValue)
|
||||
// 测量稳定性日志:三轮差异过大时告警
|
||||
if measurementPassValues.count >= 2 {
|
||||
let minVal = measurementPassValues.min() ?? 1
|
||||
let maxVal = measurementPassValues.max() ?? 1
|
||||
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
|
||||
}
|
||||
}
|
||||
pageCounts[currentSpineIndex] = finalValue
|
||||
currentMeasurementOffset += 1
|
||||
measurementPassValues = []
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
@@ -333,8 +344,10 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||
self.measurementPassValues.append(number.intValue)
|
||||
} else if let intValue = value as? Int {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||
self.measurementPassValues.append(intValue)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||
self.scheduleMeasurementPass()
|
||||
@@ -344,6 +357,20 @@ public final class RDEPUBPaginator: NSObject {
|
||||
}
|
||||
|
||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
decisionHandler(.allow)
|
||||
} else {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ extension RDEPUBParser {
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
for entry in archive {
|
||||
let destinationURL = extractionURL.appendingPathComponent(entry.path)
|
||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
||||
}
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
@@ -64,6 +66,31 @@ extension RDEPUBParser {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
/// 校验归档条目路径,防止路径穿越攻击
|
||||
/// - Parameters:
|
||||
/// - entryPath: 归档中的原始条目路径
|
||||
/// - extractionRoot: 解压根目录
|
||||
/// - Returns: 校验通过的目标 URL,非法路径返回 nil
|
||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
||||
// 拒绝绝对路径
|
||||
if entryPath.hasPrefix("/") {
|
||||
return nil
|
||||
}
|
||||
// 拒绝包含 .. 的路径段
|
||||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
||||
if components.contains(where: { $0 == ".." }) {
|
||||
return nil
|
||||
}
|
||||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
||||
let standardizedDest = destinationURL.standardizedFileURL.path
|
||||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
||||
// 确保最终路径位于解压根目录下
|
||||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
||||
return nil
|
||||
}
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
/// 计算 EPUB 的临时解压目录路径
|
||||
/// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
|
||||
@@ -76,6 +76,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) -> RDEPUBRenderRequest? {
|
||||
@@ -104,6 +105,7 @@ public struct RDEPUBPreferences: Equatable {
|
||||
totalPagesInChapter: page.totalPagesInChapter,
|
||||
presentation: presentationStyle(viewportSize: viewportSize),
|
||||
targetLocation: targetLocation,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
||||
highlights: highlights,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ public final class RDEPUBReadingSession {
|
||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
||||
/// 挂起的导航目标页码
|
||||
public private(set) var pendingNavigationPageNum: Int?
|
||||
/// 挂起的高亮 Range 信息(仅 Web 渲染路径使用)
|
||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
||||
/// 当前视口信息
|
||||
public private(set) var currentViewport: RDEPUBViewport?
|
||||
/// 当前阅读上下文(位置 + 视口 + 页码 + 章节)
|
||||
@@ -105,6 +107,7 @@ public final class RDEPUBReadingSession {
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
pendingNavigationHighlightRangeInfo = nil
|
||||
}
|
||||
|
||||
/// 检查指定页码和 spine 索引是否有挂起的导航位置
|
||||
@@ -123,6 +126,26 @@ public final class RDEPUBReadingSession {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
/// 查询指定页是否存在待消费的高亮 Range 信息。
|
||||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationHighlightRangeInfo,
|
||||
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let pendingNavigationLocation else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
guard let spineIndex else {
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return pendingNavigationHighlightRangeInfo
|
||||
}
|
||||
|
||||
/// 判断指定页面是否包含给定的 spine 索引(固定版式需检查 spread 内所有资源)
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
@@ -225,7 +248,8 @@ public final class RDEPUBReadingSession {
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
bookIdentifier: String?,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Int? {
|
||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
@@ -235,8 +259,14 @@ public final class RDEPUBReadingSession {
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
|
||||
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
|
||||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
||||
let shouldKeepPendingNavigation =
|
||||
normalizedLocation.fragment != nil ||
|
||||
normalizedLocation.rangeAnchor != nil ||
|
||||
hasTargetHighlightRangeInfo
|
||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
||||
transition(to: .jumping)
|
||||
return pageIndex + 1
|
||||
}
|
||||
@@ -363,4 +393,4 @@ public final class RDEPUBReadingSession {
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
public var presentation: RDEPUBPresentationStyle
|
||||
/// 目标跳转位置(用于恢复阅读位置或锚点跳转)
|
||||
public var targetLocation: RDEPUBLocation?
|
||||
/// 目标高亮的 DOM Range 信息,用于 Web 渲染路径下的精确跳转
|
||||
public var targetHighlightRangeInfo: String?
|
||||
/// 高亮列表
|
||||
public var highlights: [RDEPUBHighlight]
|
||||
/// 搜索结果展示信息
|
||||
@@ -97,6 +99,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
totalPagesInChapter: Int,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
targetHighlightRangeInfo: String? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
@@ -106,6 +109,7 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.presentation = presentation
|
||||
self.targetLocation = targetLocation
|
||||
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
||||
self.highlights = highlights
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import WebKit
|
||||
|
||||
/// 自定义 URL 协议处理器,将 ss-reader://book/ 请求映射到本地 EPUB 资源文件
|
||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
struct DebugMetrics {
|
||||
let streamedResponses: Int
|
||||
let inMemoryResponses: Int
|
||||
let failures: Int
|
||||
}
|
||||
|
||||
/// 自定义协议名
|
||||
public static let scheme = "ss-reader"
|
||||
/// 自定义主机名
|
||||
@@ -21,12 +27,34 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
||||
/// 活跃的 URL Scheme 任务集合(用于任务取消检测)
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
||||
private static var streamedResponseCount = 0
|
||||
private static var inMemoryResponseCount = 0
|
||||
private static var failureCount = 0
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func resetDebugMetrics() {
|
||||
debugMetricsQueue.sync {
|
||||
streamedResponseCount = 0
|
||||
inMemoryResponseCount = 0
|
||||
failureCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
static func debugMetricsSnapshot() -> DebugMetrics {
|
||||
debugMetricsQueue.sync {
|
||||
DebugMetrics(
|
||||
streamedResponses: streamedResponseCount,
|
||||
inMemoryResponses: inMemoryResponseCount,
|
||||
failures: failureCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 URL Scheme 请求:解析文件路径、读取数据、返回响应
|
||||
/// 对缺失的可选资源(字体/图片/CSS/JS)返回空响应,其他返回 404 错误
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
@@ -55,6 +83,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
urlSchemeTask.didFinish()
|
||||
} else {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
clearTask(taskID)
|
||||
@@ -63,27 +92,12 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard isTaskActive(taskID) else { return }
|
||||
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
let fileSize = resourceSize(for: fileURL)
|
||||
if let fileSize, fileSize > 524_288 {
|
||||
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
||||
} else {
|
||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
||||
}
|
||||
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
/// 取消 URL Scheme 任务
|
||||
@@ -106,6 +120,89 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
}
|
||||
}
|
||||
|
||||
private func resourceSize(for url: URL) -> UInt64? {
|
||||
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? UInt64 else {
|
||||
return nil
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordInMemoryResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
Self.recordFailure()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
}
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: Int(fileSize),
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
Self.recordFailure()
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
clearTask(taskID)
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = 65_536
|
||||
defer {
|
||||
fileHandle.closeFile()
|
||||
clearTask(taskID)
|
||||
}
|
||||
while true {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
let data = fileHandle.readData(ofLength: chunkSize)
|
||||
if data.isEmpty { break }
|
||||
urlSchemeTask.didReceive(data)
|
||||
}
|
||||
urlSchemeTask.didFinish()
|
||||
Self.recordStreamedResponse()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
||||
}
|
||||
|
||||
private static func recordStreamedResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
streamedResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordInMemoryResponse() {
|
||||
debugMetricsQueue.sync {
|
||||
inMemoryResponseCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
debugMetricsQueue.sync {
|
||||
failureCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
@@ -165,4 +262,4 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ extension RDEPUBWebView {
|
||||
webView.backgroundColor = .clear
|
||||
webView.clipsToBounds = true
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
||||
}
|
||||
|
||||
addSubview(webView)
|
||||
|
||||
@@ -97,7 +97,8 @@ extension RDEPUBWebView {
|
||||
request.targetLocation?.href ?? "",
|
||||
String(request.targetLocation?.progression ?? 0),
|
||||
String(request.targetLocation?.lastProgression ?? 0),
|
||||
request.targetLocation?.fragment ?? ""
|
||||
request.targetLocation?.fragment ?? "",
|
||||
request.targetHighlightRangeInfo ?? ""
|
||||
].joined(separator: "|")
|
||||
let highlightSignature = request.highlights
|
||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||
|
||||
@@ -6,6 +6,14 @@ 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 {
|
||||
@@ -70,7 +78,7 @@ extension RDEPUBWebView {
|
||||
)
|
||||
let searchDecorations = decorationList(
|
||||
from: payload["search"] as? [[String: Any]],
|
||||
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
defaultColor: normalSearchOverlayColor
|
||||
)
|
||||
return highlightDecorations + searchDecorations
|
||||
}
|
||||
@@ -105,9 +113,9 @@ extension RDEPUBWebView {
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||||
case .activeSearch:
|
||||
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
color = activeSearchOverlayColor
|
||||
case .search:
|
||||
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
color = normalSearchOverlayColor
|
||||
case .highlight:
|
||||
let hex = item["color"] as? String ?? "#F8E16C"
|
||||
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||||
|
||||
@@ -21,6 +21,30 @@ enum RDEPUBWebViewDebug {
|
||||
#endif
|
||||
}()
|
||||
|
||||
/// 详细日志模式是否启用(默认关闭,可通过 UserDefaults "RDEPUBWebViewVerboseEnabled" 开启)
|
||||
/// 开启后 logMessage 将输出完整消息体,关闭时仅输出消息名、字段名和文本长度
|
||||
static var isVerboseEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewVerboseEnabled") as? Bool {
|
||||
return configured
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 是否允许开启 inspectable。
|
||||
/// 默认关闭,仅在阅读器配置显式允许或 UserDefaults 覆盖时开启。
|
||||
static var isInspectableEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBInspectableWebViewsEnabled") as? Bool {
|
||||
return configured
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
/// 将阅读器配置同步到调试策略,保证默认安全策略由配置显式控制。
|
||||
static func applyDebugPolicy(inspectableEnabled: Bool, verboseLoggingEnabled: Bool) {
|
||||
isInspectableEnabled = inspectableEnabled
|
||||
isVerboseEnabled = verboseLoggingEnabled
|
||||
}
|
||||
|
||||
/// 获取 WebView 的十六进制标识符(用于日志区分多个 WebView 实例)
|
||||
static func webViewID(_ webView: WKWebView?) -> String {
|
||||
guard let webView else { return "nil-webview" }
|
||||
@@ -51,10 +75,26 @@ enum RDEPUBWebViewDebug {
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
/// 输出 JS 消息接收日志(含消息名称和消息体)
|
||||
/// 输出 JS 消息接收日志(默认仅输出字段名和文本长度,verbose 模式输出完整消息体)
|
||||
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
if isVerboseEnabled {
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
|
||||
return
|
||||
}
|
||||
if let dict = body as? [String: Any] {
|
||||
let keys = dict.keys.sorted().joined(separator: ",")
|
||||
var meta = "keys=[\(keys)]"
|
||||
if let text = dict["text"] as? String {
|
||||
meta += " textLen=\(text.count)"
|
||||
}
|
||||
if let urlString = dict["url"] as? String ?? dict["href"] as? String {
|
||||
meta += " url=\(summarizedURL(URL(string: urlString)))"
|
||||
}
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) \(meta)")
|
||||
} else {
|
||||
log(scope, message: "webView=\(webViewID(webView)) message=\(name) type=\(Swift.type(of: body))")
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出 URL Scheme 任务日志(含请求 URL、文件 URL、事件类型、错误信息)
|
||||
@@ -81,4 +121,4 @@ enum RDEPUBWebViewDebug {
|
||||
}
|
||||
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(function() {
|
||||
(function() {
|
||||
if (window.RDReaderBridge) { return; }
|
||||
window.addEventListener('error', function(event) {
|
||||
try {
|
||||
@@ -470,7 +470,18 @@
|
||||
setVisiblePageIndex(pageIndex);
|
||||
reportProgression(null);
|
||||
},
|
||||
scrollToLocation: function(location, fallbackPageIndex) {
|
||||
scrollToLocation: function(location, fallbackPageIndex, targetRangeInfo) {
|
||||
if (targetRangeInfo) {
|
||||
var highlightRange = rangeFromInfo(targetRangeInfo);
|
||||
var highlightRects = rectPayloadForRange(highlightRange, 0, 0);
|
||||
if (highlightRects.length) {
|
||||
var highlightRect = highlightRects[0];
|
||||
var highlightAbsoluteLeft = Math.max(0, highlightRect.x + currentPageOffset());
|
||||
setVisiblePageIndex(Math.max(0, Math.floor(highlightAbsoluteLeft / effectivePageStride())));
|
||||
reportProgression(location && location.fragment ? location.fragment : null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (location && location.fragment) {
|
||||
var resolvedTarget = fragmentTarget(location.fragment);
|
||||
if (resolvedTarget) {
|
||||
|
||||
@@ -20,7 +20,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previous.diagnostics.append(note)
|
||||
compacted.append(previous)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
||||
#endif
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -36,7 +38,9 @@ struct RDEPUBChapterTailNormalizer {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
// 保存分页缓存(只缓存页范围和分页原因,不缓存富文本)
|
||||
cacheCoordinator.save(chapters: chapters, key: cacheKey)
|
||||
|
||||
#if DEBUG
|
||||
print(sampler.summary())
|
||||
#endif
|
||||
lastBuildPerformanceSamples = sampler.samples
|
||||
|
||||
return book
|
||||
@@ -207,11 +209,15 @@ public final class RDEPUBTextBookBuilder {
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
#endif
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -301,7 +307,9 @@ public final class RDEPUBTextBookBuilder {
|
||||
: normalizedFrames
|
||||
|
||||
if item.href.lowercased().contains("cover") {
|
||||
#if DEBUG
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
#endif
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
|
||||
@@ -170,7 +170,9 @@ public final class RDEPUBTextBookCache {
|
||||
queue.sync {
|
||||
let fileURL = cacheDirectory.appendingPathComponent(key)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
@@ -179,17 +181,23 @@ public final class RDEPUBTextBookCache {
|
||||
ofClass: PaginationCacheArchive.self,
|
||||
from: data
|
||||
) else {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
|
||||
for chapter in archive.chapters {
|
||||
result[chapter.href] = chapter.toCache()
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
|
||||
#endif
|
||||
return result
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Cache] load MISS key=\(key) error=\(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -204,9 +212,13 @@ public final class RDEPUBTextBookCache {
|
||||
let bookArchive = PaginationCacheArchive(chapters: archives)
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
#if DEBUG
|
||||
print("[Cache] save key=\(key) chapters=\(chapters.count)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[Cache] save FAILED key=\(key) error=\(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +235,9 @@ public final class RDEPUBTextBookCache {
|
||||
for file in contents {
|
||||
try? fileManager.removeItem(at: file)
|
||||
}
|
||||
#if DEBUG
|
||||
print("[Cache] invalidateAll")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -51,7 +51,9 @@ public final class RDEPUBTextPerformanceSampler {
|
||||
/// 记录单个章节的性能采样,并输出日志
|
||||
public func record(_ sample: RDEPUBTextPerformanceSample) {
|
||||
samples.append(sample)
|
||||
#if DEBUG
|
||||
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 生成性能汇总报告,包含总渲染/分页耗时和缓存命中率
|
||||
|
||||
@@ -166,80 +166,6 @@ struct RDEPUBPageBreakPolicy {
|
||||
|
||||
// MARK: - 语义边界查找
|
||||
|
||||
/// 在指定范围内查找最优的语义分页点(pageBreakBefore / pageBreakAfter)。
|
||||
func preferredSemanticBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> (location: Int, trigger: String)? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: (location: Int, trigger: String)?
|
||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
||||
guard let rawValue = value as? String else { return }
|
||||
let hints = rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
guard !hints.isEmpty else { return }
|
||||
|
||||
if hints.contains(.pageBreakBefore),
|
||||
attributeRange.location > safeRange.location,
|
||||
attributeRange.location >= minimumEnd {
|
||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
|
||||
let attributeEnd = attributeRange.location + attributeRange.length
|
||||
if hints.contains(.pageBreakAfter),
|
||||
attributeEnd > minimumEnd,
|
||||
attributeEnd < safeRange.location + safeRange.length {
|
||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找附件边界:只有块级附件才触发分页。
|
||||
func preferredAttachmentBoundary(
|
||||
in range: NSRange,
|
||||
minimumEnd: Int,
|
||||
factory: RDEPUBCoreTextPageFrameFactory
|
||||
) -> Int? {
|
||||
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = factory.attachmentPlacement(at: location)
|
||||
let blockKind = factory.blockKind(at: location)
|
||||
|
||||
let isBlockLevelAttachment: Bool
|
||||
switch placement {
|
||||
case .centered:
|
||||
isBlockLevelAttachment = true
|
||||
case .inline, .baseline:
|
||||
isBlockLevelAttachment = false
|
||||
case nil:
|
||||
isBlockLevelAttachment = blockKind == .attachment
|
||||
}
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
|
||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
/// 查找 pageRelate 跨页关联边界。
|
||||
func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
|
||||
@@ -174,12 +174,12 @@ public final class RDEPUBChapterData {
|
||||
|
||||
/// 从搜索结果还原绝对字符范围
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: max(searchMatch.rangeLength, 1))
|
||||
}
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.chapterRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -187,11 +187,7 @@ public struct RDEPUBTextIndexTable {
|
||||
|
||||
/// 将锚点转换为章节内字符偏移量。
|
||||
public func chapterOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
chapterOffset(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
anchor.chapterOffset
|
||||
}
|
||||
|
||||
/// 将锚点转换为全书绝对字符索引。
|
||||
|
||||
@@ -46,7 +46,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogFootnoteAttachment {
|
||||
didLogFootnoteAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -66,7 +68,9 @@ struct RDEPUBAttachmentNormalizer {
|
||||
|
||||
if !didLogCoverAttachment {
|
||||
didLogCoverAttachment = true
|
||||
#if DEBUG
|
||||
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
|
||||
#endif
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,15 +62,17 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
|
||||
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
|
||||
}
|
||||
|
||||
/// Web 内容视图外部链接点击回调,使用系统浏览器打开
|
||||
/// Web 内容视图外部链接点击回调,根据配置策略决定是否打开
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubReader(self, didActivateExternalLink: url)
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
openExternalURLIfAllowed(url)
|
||||
}
|
||||
|
||||
/// Web 内容视图 JavaScript 错误日志回调
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
|
||||
#if DEBUG
|
||||
print("EPUB JS Error: \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +98,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
contentView.clearSelection()
|
||||
}
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didActivateAttachmentText text: String,
|
||||
sourceRect: CGRect
|
||||
) {
|
||||
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect)
|
||||
}
|
||||
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
@@ -296,4 +306,242 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
}
|
||||
return bestID
|
||||
}
|
||||
|
||||
// MARK: - 外部链接策略
|
||||
|
||||
private func shouldAllowExternalURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else { return false }
|
||||
if delegate?.epubReader(self, shouldOpenExternalURL: url) == false {
|
||||
return false
|
||||
}
|
||||
return configuration.allowedExternalURLSchemes.contains(scheme)
|
||||
}
|
||||
|
||||
private func openExternalURLIfAllowed(_ url: URL) {
|
||||
guard shouldAllowExternalURL(url) else { return }
|
||||
if configuration.requiresExternalLinkConfirmation {
|
||||
presentExternalLinkConfirmation(for: url)
|
||||
} else {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentExternalLinkConfirmation(for url: URL) {
|
||||
let alert = UIAlertController(
|
||||
title: "打开外部链接",
|
||||
message: url.absoluteString,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "打开", style: .default) { _ in
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) {
|
||||
hideAttachmentTooltipIfNeeded()
|
||||
|
||||
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.onBackgroundTap = { [weak self, weak overlay] in
|
||||
guard let self, let overlay else { return }
|
||||
self.dismissAttachmentTooltipOverlay(overlay)
|
||||
}
|
||||
|
||||
let tooltip = RDEPUBAttachmentTooltipView()
|
||||
tooltip.alpha = 0
|
||||
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320))
|
||||
|
||||
let anchorRect = sourceView.convert(sourceRect, to: view)
|
||||
let horizontalPadding: CGFloat = 24
|
||||
let verticalSpacing: CGFloat = 6
|
||||
let tooltipSize = tooltip.frame.size
|
||||
let idealX = anchorRect.midX - tooltipSize.width / 2
|
||||
let minX = horizontalPadding
|
||||
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
|
||||
let originX = min(max(idealX, minX), maxX)
|
||||
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing)
|
||||
let arrowTipX = min(
|
||||
max(anchorRect.midX - originX, tooltip.minimumArrowX),
|
||||
tooltipSize.width - tooltip.minimumArrowX
|
||||
)
|
||||
|
||||
tooltip.setArrowTipX(arrowTipX)
|
||||
tooltip.frame.origin = CGPoint(x: originX, y: originY)
|
||||
overlay.addSubview(tooltip)
|
||||
view.addSubview(overlay)
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
tooltip.alpha = 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self, weak overlay] in
|
||||
guard let self, let overlay else { return }
|
||||
self.dismissAttachmentTooltipOverlay(overlay)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideAttachmentTooltipIfNeeded() {
|
||||
view.subviews
|
||||
.compactMap { $0 as? RDEPUBAttachmentTooltipOverlayView }
|
||||
.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
|
||||
private func dismissAttachmentTooltipOverlay(_ overlay: RDEPUBAttachmentTooltipOverlayView) {
|
||||
UIView.animate(withDuration: 0.18, animations: {
|
||||
overlay.tooltipView?.alpha = 0
|
||||
}, completion: { _ in
|
||||
overlay.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipOverlayView: UIView {
|
||||
var onBackgroundTap: (() -> Void)?
|
||||
|
||||
var tooltipView: RDEPUBAttachmentTooltipView? {
|
||||
subviews.compactMap { $0 as? RDEPUBAttachmentTooltipView }.first
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: self)
|
||||
guard let tooltipView else {
|
||||
onBackgroundTap?()
|
||||
return
|
||||
}
|
||||
if !tooltipView.frame.contains(point) {
|
||||
onBackgroundTap?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBAttachmentTooltipView: UIView {
|
||||
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
|
||||
private let arrowSize = CGSize(width: 20, height: 10)
|
||||
private let cornerRadius: CGFloat = 18
|
||||
private(set) var minimumArrowX: CGFloat = 28
|
||||
private var arrowTipX: CGFloat?
|
||||
private let textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 16, weight: .regular)
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
return label
|
||||
}()
|
||||
private let shapeLayer = CAShapeLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
layer.addSublayer(shapeLayer)
|
||||
addSubview(textLabel)
|
||||
accessibilityIdentifier = "epub.reader.attachment.tooltip"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
shapeLayer.frame = bounds
|
||||
shapeLayer.path = bubblePath(in: bounds).cgPath
|
||||
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
|
||||
|
||||
let labelFrame = bounds.inset(by: UIEdgeInsets(
|
||||
top: contentInsets.top,
|
||||
left: contentInsets.left,
|
||||
bottom: contentInsets.bottom + arrowSize.height,
|
||||
right: contentInsets.right
|
||||
))
|
||||
textLabel.frame = labelFrame
|
||||
}
|
||||
|
||||
func setArrowTipX(_ value: CGFloat) {
|
||||
arrowTipX = value
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func configure(text: String, maxWidth: CGFloat) {
|
||||
textLabel.text = text
|
||||
let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120)
|
||||
let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude))
|
||||
frame.size = CGSize(
|
||||
width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right),
|
||||
height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height
|
||||
)
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func bubblePath(in rect: CGRect) -> UIBezierPath {
|
||||
let bubbleRect = CGRect(
|
||||
x: rect.minX,
|
||||
y: rect.minY,
|
||||
width: rect.width,
|
||||
height: rect.height - arrowSize.height
|
||||
)
|
||||
let arrowMidX = min(
|
||||
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
|
||||
bubbleRect.width - minimumArrowX
|
||||
)
|
||||
let arrowHalfWidth = arrowSize.width / 2
|
||||
|
||||
let path = UIBezierPath()
|
||||
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
|
||||
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
|
||||
path.addArc(
|
||||
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: -.pi / 2,
|
||||
endAngle: 0,
|
||||
clockwise: true
|
||||
)
|
||||
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
|
||||
path.addArc(
|
||||
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: 0,
|
||||
endAngle: .pi / 2,
|
||||
clockwise: true
|
||||
)
|
||||
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
|
||||
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
|
||||
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
|
||||
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
|
||||
path.addArc(
|
||||
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: .pi / 2,
|
||||
endAngle: .pi,
|
||||
clockwise: true
|
||||
)
|
||||
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
|
||||
path.addArc(
|
||||
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: .pi,
|
||||
endAngle: -.pi / 2,
|
||||
clockwise: true
|
||||
)
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,16 +97,28 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||
guard let globalSearchState = searchState else { return nil }
|
||||
|
||||
let matches = globalSearchState.matches.filter { searchMatch in
|
||||
searchMatchBelongsToPage(searchMatch, page: page)
|
||||
let matches: [RDEPUBSearchMatch]
|
||||
let currentMatchIndex: Int?
|
||||
|
||||
if let chapterData = chapterData(for: page),
|
||||
let resolvedState = resolvedSearchState(
|
||||
for: page,
|
||||
chapterData: chapterData,
|
||||
globalSearchState: globalSearchState
|
||||
) {
|
||||
matches = resolvedState.matches
|
||||
currentMatchIndex = resolvedState.currentMatchIndex
|
||||
} else {
|
||||
matches = globalSearchState.matches.filter { searchMatch in
|
||||
searchMatchBelongsToPage(searchMatch, page: page)
|
||||
}
|
||||
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||
matches.firstIndex(of: currentMatch)
|
||||
}
|
||||
}
|
||||
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||
matches.firstIndex(of: currentMatch)
|
||||
}
|
||||
return RDEPUBSearchState(
|
||||
keyword: globalSearchState.keyword,
|
||||
matches: matches,
|
||||
@@ -114,6 +126,126 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
private func resolvedSearchState(
|
||||
for page: RDEPUBTextPage,
|
||||
chapterData: RDEPUBChapterData,
|
||||
globalSearchState: RDEPUBSearchState
|
||||
) -> RDEPUBSearchState? {
|
||||
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
let normalizedHref = normalizedPageHref(for: page)
|
||||
let exactMatches = exactChapterSearchMatches(
|
||||
in: chapterData,
|
||||
keyword: normalizedKeyword,
|
||||
normalizedHref: normalizedHref
|
||||
)
|
||||
let pageMatches = exactMatches.filter { match in
|
||||
guard let rangeLocation = match.rangeLocation else { return false }
|
||||
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
|
||||
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
|
||||
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
|
||||
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
|
||||
}
|
||||
|
||||
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
|
||||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||
}
|
||||
|
||||
return RDEPUBSearchState(
|
||||
keyword: globalSearchState.keyword,
|
||||
matches: pageMatches,
|
||||
currentMatchIndex: currentMatchIndex
|
||||
)
|
||||
}
|
||||
|
||||
private func exactChapterSearchMatches(
|
||||
in chapterData: RDEPUBChapterData,
|
||||
keyword: String,
|
||||
normalizedHref: String
|
||||
) -> [RDEPUBSearchMatch] {
|
||||
let source = chapterData.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return [] }
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: max(foundRange.length, 1),
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
return chapterData
|
||||
}
|
||||
|
||||
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
|
||||
}
|
||||
|
||||
private func makeChapterData(
|
||||
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||
chapterIndex: Int
|
||||
) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
href: runtimeChapter.href,
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
|
||||
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href),
|
||||
|
||||
@@ -60,11 +60,16 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
let page = activePages[pageIndex]
|
||||
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
|
||||
let pendingHighlightRangeInfo = readingSession?.pendingHighlightRangeInfo(
|
||||
forPageNumber: pageIndex + 1,
|
||||
spineIndex: page.spineIndex
|
||||
)
|
||||
return currentPreferences().renderRequest(
|
||||
for: page,
|
||||
publication: publication,
|
||||
viewportSize: currentLayoutContext().viewportSize,
|
||||
targetLocation: pendingLocation,
|
||||
targetHighlightRangeInfo: pendingHighlightRangeInfo,
|
||||
highlights: highlights(for: page),
|
||||
searchPresentation: searchPresentation(for: page)
|
||||
)
|
||||
@@ -97,4 +102,3 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +91,16 @@ extension RDEPUBReaderController {
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
runtime.restoreReadingLocation(location, animated: animated)
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
animated: Bool = false,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Bool {
|
||||
runtime.restoreReadingLocation(
|
||||
location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的位置
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
public var configuration: RDEPUBReaderConfiguration {
|
||||
didSet {
|
||||
readerContext.configuration = configuration
|
||||
applyWebViewDebugPolicy()
|
||||
persistReaderSettingsIfNeeded()
|
||||
guard isViewLoaded else { return }
|
||||
let oldConfiguration = oldValue
|
||||
@@ -230,12 +231,20 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
readerContext.epubURL = epubURL
|
||||
readerContext.persistence = persistence
|
||||
self.currentBrightness = brightness
|
||||
applyWebViewDebugPolicy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func applyWebViewDebugPolicy() {
|
||||
RDEPUBWebViewDebug.applyDebugPolicy(
|
||||
inspectableEnabled: configuration.allowsInspectableWebViews,
|
||||
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程
|
||||
/// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook
|
||||
/// - Parameters:
|
||||
@@ -318,9 +327,21 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchBarView.apply(theme: configuration.theme)
|
||||
|
||||
searchBarView.onSearchSubmit = { [weak self] keyword in
|
||||
self?.searchBarView.showSearching()
|
||||
self?.runtime.search(keyword: keyword)
|
||||
self?.updateSearchCount()
|
||||
}
|
||||
searchBarView.onSearchTextChanged = { [weak self] keyword in
|
||||
guard let self else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalizedKeyword.isEmpty {
|
||||
self.runtime.clearSearch()
|
||||
} else {
|
||||
self.searchBarView.showSearching()
|
||||
self.runtime.search(keyword: normalizedKeyword)
|
||||
}
|
||||
self.updateSearchCount()
|
||||
}
|
||||
searchBarView.onSearchPrevious = { [weak self] in
|
||||
_ = self?.runtime.searchPrevious()
|
||||
self?.updateSearchCount()
|
||||
@@ -329,6 +350,14 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
_ = self?.runtime.searchNext()
|
||||
self?.updateSearchCount()
|
||||
}
|
||||
searchBarView.onSelectMatch = { [weak self] matchIndex in
|
||||
guard let self else { return }
|
||||
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
|
||||
self.updateSearchCount()
|
||||
if didNavigate {
|
||||
self.hideSearchBar(clearSearch: false)
|
||||
}
|
||||
}
|
||||
searchBarView.onClose = { [weak self] in
|
||||
self?.hideSearchBar(clearSearch: true)
|
||||
}
|
||||
@@ -345,22 +374,28 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|
||||
readerView.addSubview(searchBarView)
|
||||
readerView.searchBarView = searchBarView
|
||||
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
||||
let bottomAnchor = bottomToolView.superview == nil
|
||||
? readerView.bottomAnchor
|
||||
: bottomToolView.topAnchor
|
||||
NSLayoutConstraint.activate([
|
||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
|
||||
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.searchBarView.transform = .identity
|
||||
searchBarView.alpha = 0
|
||||
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||
UIView.animate(withDuration: 0.28) {
|
||||
self.searchBarView.alpha = 1
|
||||
self.searchBarView.presentedView.transform = .identity
|
||||
}
|
||||
|
||||
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
||||
searchBarView.restoreKeyword(keyword)
|
||||
updateSearchCount()
|
||||
} else {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -375,11 +410,13 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
isSearchBarVisible = false
|
||||
|
||||
searchBarView.textField.resignFirstResponder()
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.searchBarView.alpha = 0
|
||||
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
|
||||
}) { _ in
|
||||
self.searchBarView.removeFromSuperview()
|
||||
self.searchBarView.transform = .identity
|
||||
self.searchBarView.alpha = 1
|
||||
self.searchBarView.presentedView.transform = .identity
|
||||
self.readerView.searchBarView = nil
|
||||
}
|
||||
|
||||
@@ -394,11 +431,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
searchBarView.showNoResults()
|
||||
return
|
||||
}
|
||||
if let index = searchState.currentMatchIndex {
|
||||
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
searchBarView.showNoResults()
|
||||
}
|
||||
searchBarView.updateResults(
|
||||
sections: searchResultSections(for: searchState),
|
||||
keyword: searchState.keyword,
|
||||
currentMatchIndex: searchState.currentMatchIndex
|
||||
)
|
||||
}
|
||||
|
||||
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||
@@ -417,3 +454,53 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension RDEPUBReaderController {
|
||||
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
|
||||
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
|
||||
searchSectionTitle(for: entry.element)
|
||||
})
|
||||
|
||||
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
|
||||
let title = searchSectionTitle(for: match)
|
||||
if titles.last != title, titles.contains(title) == false {
|
||||
titles.append(title)
|
||||
}
|
||||
}
|
||||
|
||||
return orderedTitles.compactMap { title in
|
||||
guard let matches = groupedMatches[title] else { return nil }
|
||||
let items = matches.map { offset, match in
|
||||
RDEPUBReaderSearchSection.Item(
|
||||
matchIndex: offset,
|
||||
previewText: match.previewText,
|
||||
isCurrent: offset == searchState.currentMatchIndex
|
||||
)
|
||||
}
|
||||
return RDEPUBReaderSearchSection(title: title, items: items)
|
||||
}
|
||||
}
|
||||
|
||||
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
|
||||
guard let publication else {
|
||||
return match.href
|
||||
}
|
||||
|
||||
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
|
||||
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
|
||||
if let tocItem = tocItems.last(where: {
|
||||
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
|
||||
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
|
||||
return normalizedItemHref == normalizedMatchHref
|
||||
}) {
|
||||
return tocItem.title
|
||||
}
|
||||
|
||||
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
|
||||
publication.spine.indices.contains(spineIndex) {
|
||||
return publication.spine[spineIndex].title
|
||||
}
|
||||
|
||||
return normalizedMatchHref
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
/// - url: 外部链接 URL
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
|
||||
/// 外部链接打开前的拦截钩子,返回 false 可阻止打开
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
/// - url: 外部链接 URL
|
||||
/// - Returns: 是否允许打开该链接
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
|
||||
|
||||
/// 阅读器发生错误时调用
|
||||
/// - Parameters:
|
||||
/// - reader: 阅读器控制器
|
||||
@@ -92,6 +99,7 @@ public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool { true }
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
||||
}
|
||||
|
||||
@@ -39,23 +39,32 @@ public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
// MARK: - 默认实现
|
||||
|
||||
/// 协议的默认空实现,书签/高亮/设置为可选功能
|
||||
/// DEBUG 模式下会对 no-op 行为输出警告,便于发现未对接持久化层的误用
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ loadBookmarks called on default no-op implementation for: \(bookIdentifier)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
_ = bookmarks
|
||||
_ = bookIdentifier
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ saveBookmarks(\(bookmarks.count) items) called on default no-op implementation for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
nil
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ loadReaderSettings called on default no-op implementation")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
_ = settings
|
||||
#if DEBUG
|
||||
print("[RDEPUBReaderPersistence] ⚠️ saveReaderSettings called on default no-op implementation")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +145,11 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else {
|
||||
return
|
||||
}
|
||||
if data.count > 1_048_576 {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,32 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 搜索栏
|
||||
struct RDEPUBReaderSearchSection: Equatable {
|
||||
struct Item: Equatable {
|
||||
let matchIndex: Int
|
||||
let previewText: String
|
||||
let isCurrent: Bool
|
||||
}
|
||||
|
||||
/// 阅读器搜索栏视图
|
||||
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||||
let title: String
|
||||
let items: [Item]
|
||||
}
|
||||
|
||||
// MARK: - 搜索面板
|
||||
|
||||
/// 阅读器搜索面板
|
||||
/// 提供底部抽屉式搜索输入、分组结果列表和匹配跳转能力。
|
||||
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
// MARK: 回调闭包
|
||||
|
||||
/// 提交搜索关键词回调
|
||||
var onSearchSubmit: ((String) -> Void)?
|
||||
/// 点击上一个匹配回调
|
||||
var onSearchTextChanged: ((String) -> Void)?
|
||||
var onSearchPrevious: (() -> Void)?
|
||||
/// 点击下一个匹配回调
|
||||
var onSearchNext: (() -> Void)?
|
||||
/// 关闭搜索回调
|
||||
var onSelectMatch: ((Int) -> Void)?
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
// MARK: UI 组件
|
||||
|
||||
private let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.cornerRadius = 8
|
||||
view.layer.masksToBounds = true
|
||||
view.isAccessibilityElement = false
|
||||
view.accessibilityElementsHidden = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let searchIcon: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||||
if #available(iOS 13.0, *) {
|
||||
imageView.image = UIImage(systemName: "magnifyingglass")
|
||||
}
|
||||
imageView.tintColor = .gray
|
||||
return imageView
|
||||
}()
|
||||
|
||||
let textField: UITextField = {
|
||||
let field = UITextField()
|
||||
field.placeholder = "搜索..."
|
||||
field.font = UIFont.systemFont(ofSize: 15)
|
||||
field.placeholder = "搜索"
|
||||
field.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||
field.returnKeyType = .search
|
||||
field.autocorrectionType = .no
|
||||
field.autocapitalizationType = .none
|
||||
@@ -53,25 +38,25 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
return field
|
||||
}()
|
||||
|
||||
private let backgroundButton = UIButton(type: .custom)
|
||||
private let panelView = UIView()
|
||||
private let grabberView = UIView()
|
||||
private let searchRowView = UIView()
|
||||
private let searchFieldContainer = UIView()
|
||||
private let searchIcon = UIImageView()
|
||||
private let searchFieldDivider = UIView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
// Preserve legacy accessibility hooks used by existing tests and demo logic.
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let countLabel = UILabel()
|
||||
|
||||
private let countLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
// MARK: 布局常量
|
||||
|
||||
private let horizontalInset: CGFloat = 12
|
||||
private let spacing: CGFloat = 6
|
||||
private let containerHeight: CGFloat = 36
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
private var keyword = ""
|
||||
private var currentMatchIndex: Int?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -81,162 +66,331 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
setupActions()
|
||||
updateNavigationEnabled(false)
|
||||
updateLegacyNavigationEnabled(false)
|
||||
showInitialState()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: 布局
|
||||
|
||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||
.zero
|
||||
}
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
backgroundColor = theme.toolBackgroundColor
|
||||
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||||
searchIcon.tintColor = theme.toolControlTextColor
|
||||
textField.textColor = theme.toolControlTextColor
|
||||
let isDarkBackground = theme.contentBackgroundColor.rd_searchIsDarkBackground
|
||||
|
||||
let overlayColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 0.92)
|
||||
: UIColor(white: 0.08, alpha: 0.82)
|
||||
let panelColor = isDarkBackground
|
||||
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
||||
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
||||
let rowColor = isDarkBackground
|
||||
? UIColor(white: 0.18, alpha: 1)
|
||||
: UIColor(white: 0.14, alpha: 0.96)
|
||||
let cardColor = isDarkBackground
|
||||
? UIColor(white: 0.12, alpha: 1)
|
||||
: UIColor(white: 0.10, alpha: 0.98)
|
||||
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
let textColor = UIColor(white: 0.96, alpha: 1)
|
||||
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
|
||||
|
||||
backgroundColor = .clear
|
||||
backgroundButton.backgroundColor = overlayColor
|
||||
panelView.backgroundColor = panelColor
|
||||
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||
searchRowView.backgroundColor = rowColor
|
||||
searchFieldContainer.backgroundColor = .clear
|
||||
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
||||
searchIcon.tintColor = secondaryTextColor
|
||||
cancelButton.tintColor = textColor
|
||||
cancelButton.setTitleColor(textColor, for: .normal)
|
||||
textField.textColor = textColor
|
||||
textField.tintColor = UIColor.systemBlue
|
||||
textField.keyboardAppearance = isDarkBackground ? .dark : .default
|
||||
textField.attributedPlaceholder = NSAttributedString(
|
||||
string: "搜索...",
|
||||
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||||
string: "搜索",
|
||||
attributes: [.foregroundColor: secondaryTextColor]
|
||||
)
|
||||
countLabel.textColor = theme.toolControlTextColor
|
||||
previousButton.tintColor = theme.toolControlTextColor
|
||||
nextButton.tintColor = theme.toolControlTextColor
|
||||
closeButton.tintColor = theme.toolControlTextColor
|
||||
|
||||
emptyStateLabel.textColor = secondaryTextColor
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
previousButton.tintColor = textColor
|
||||
nextButton.tintColor = textColor
|
||||
countLabel.textColor = textColor
|
||||
countLabel.backgroundColor = .clear
|
||||
|
||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: 公开方法
|
||||
var presentedView: UIView {
|
||||
panelView
|
||||
}
|
||||
|
||||
/// 更新匹配计数显示
|
||||
func updateMatchCount(current: Int, total: Int) {
|
||||
countLabel.text = "\(current)/\(total)"
|
||||
updateNavigationEnabled(total > 0)
|
||||
textField.accessibilityValue = "\(current)/\(total)"
|
||||
updateLegacyNavigationEnabled(total > 0)
|
||||
}
|
||||
|
||||
/// 显示无结果状态
|
||||
func showNoResults() {
|
||||
countLabel.text = "0/0"
|
||||
updateNavigationEnabled(false)
|
||||
currentMatchIndex = nil
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = keyword.isEmpty ? "输入关键词开始搜索" : "未找到相关内容"
|
||||
}
|
||||
|
||||
/// 显示搜索中状态
|
||||
func showSearching() {
|
||||
countLabel.text = "搜索中..."
|
||||
updateNavigationEnabled(false)
|
||||
updateMatchCount(current: 0, total: 0)
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = "搜索中..."
|
||||
}
|
||||
|
||||
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||||
func restoreKeyword(_ keyword: String) {
|
||||
textField.text = keyword
|
||||
self.keyword = keyword
|
||||
}
|
||||
|
||||
// MARK: 私有方法
|
||||
func updateResults(
|
||||
sections: [RDEPUBReaderSearchSection],
|
||||
keyword: String,
|
||||
currentMatchIndex: Int?
|
||||
) {
|
||||
self.keyword = keyword
|
||||
self.searchSections = sections
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
|
||||
let total = sections.reduce(0) { $0 + $1.items.count }
|
||||
if let currentMatchIndex, total > 0 {
|
||||
updateMatchCount(current: currentMatchIndex + 1, total: total)
|
||||
} else {
|
||||
updateMatchCount(current: 0, total: total)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
showNoResults()
|
||||
return
|
||||
}
|
||||
|
||||
emptyStateLabel.isHidden = true
|
||||
tableView.isHidden = false
|
||||
tableView.reloadData()
|
||||
scrollToCurrentMatchIfNeeded()
|
||||
}
|
||||
|
||||
private func setupSubviews() {
|
||||
addSubview(containerView)
|
||||
containerView.addSubview(searchIcon)
|
||||
containerView.addSubview(textField)
|
||||
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
panelView.translatesAutoresizingMaskIntoConstraints = false
|
||||
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchRowView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchFieldDivider.translatesAutoresizingMaskIntoConstraints = false
|
||||
cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
previousButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
nextButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
countLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(backgroundButton)
|
||||
addSubview(panelView)
|
||||
|
||||
panelView.addSubview(grabberView)
|
||||
panelView.addSubview(searchRowView)
|
||||
panelView.addSubview(tableView)
|
||||
panelView.addSubview(emptyStateLabel)
|
||||
|
||||
searchRowView.addSubview(searchFieldContainer)
|
||||
searchRowView.addSubview(searchFieldDivider)
|
||||
searchRowView.addSubview(cancelButton)
|
||||
searchFieldContainer.addSubview(searchIcon)
|
||||
searchFieldContainer.addSubview(textField)
|
||||
|
||||
// Legacy shims
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
addSubview(closeButton)
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
} else {
|
||||
previousButton.setTitle("▲", for: .normal)
|
||||
nextButton.setTitle("▼", for: .normal)
|
||||
closeButton.setTitle("✕", for: .normal)
|
||||
}
|
||||
|
||||
searchIcon.contentMode = .scaleAspectFit
|
||||
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||
|
||||
panelView.layer.cornerRadius = 28
|
||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
panelView.clipsToBounds = true
|
||||
|
||||
grabberView.layer.cornerRadius = 3
|
||||
searchRowView.layer.cornerRadius = 22
|
||||
searchFieldContainer.layer.cornerRadius = 22
|
||||
searchRowView.clipsToBounds = true
|
||||
|
||||
cancelButton.setTitle("取消", for: .normal)
|
||||
cancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
|
||||
cancelButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
|
||||
emptyStateLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
|
||||
emptyStateLabel.textAlignment = .center
|
||||
emptyStateLabel.numberOfLines = 0
|
||||
|
||||
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||
previousButton.alpha = 0.01
|
||||
nextButton.alpha = 0.01
|
||||
countLabel.alpha = 0.01
|
||||
|
||||
[previousButton, nextButton, closeButton].forEach { button in
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
tableView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 16, right: 0)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// 容器(搜索输入区域)
|
||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||||
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||
backgroundButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
backgroundButton.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
backgroundButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
backgroundButton.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 搜索图标
|
||||
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
// 输入框
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||||
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||||
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||
grabberView.centerXAnchor.constraint(equalTo: panelView.centerXAnchor),
|
||||
grabberView.widthAnchor.constraint(equalToConstant: 92),
|
||||
grabberView.heightAnchor.constraint(equalToConstant: 6),
|
||||
|
||||
// 上一个按钮
|
||||
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||||
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
||||
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||
|
||||
// 下一个按钮
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||||
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||
searchFieldContainer.topAnchor.constraint(equalTo: searchRowView.topAnchor),
|
||||
searchFieldContainer.bottomAnchor.constraint(equalTo: searchRowView.bottomAnchor),
|
||||
|
||||
// 计数标签
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||||
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
searchIcon.leadingAnchor.constraint(equalTo: searchFieldContainer.leadingAnchor, constant: 10),
|
||||
searchIcon.centerYAnchor.constraint(equalTo: searchFieldContainer.centerYAnchor),
|
||||
searchIcon.widthAnchor.constraint(equalToConstant: 24),
|
||||
searchIcon.heightAnchor.constraint(equalToConstant: 24),
|
||||
|
||||
// 关闭按钮
|
||||
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||||
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||||
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||||
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 10),
|
||||
textField.trailingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: -10),
|
||||
textField.topAnchor.constraint(equalTo: searchFieldContainer.topAnchor),
|
||||
textField.bottomAnchor.constraint(equalTo: searchFieldContainer.bottomAnchor),
|
||||
|
||||
searchFieldDivider.leadingAnchor.constraint(equalTo: searchFieldContainer.trailingAnchor, constant: 12),
|
||||
searchFieldDivider.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||
searchFieldDivider.widthAnchor.constraint(equalToConstant: 1),
|
||||
searchFieldDivider.heightAnchor.constraint(equalToConstant: 28),
|
||||
|
||||
cancelButton.leadingAnchor.constraint(equalTo: searchFieldDivider.trailingAnchor, constant: 18),
|
||||
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
|
||||
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||
|
||||
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
|
||||
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
|
||||
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
|
||||
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
|
||||
|
||||
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
|
||||
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
||||
|
||||
previousButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
nextButton.topAnchor.constraint(equalTo: topAnchor),
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
countLabel.topAnchor.constraint(equalTo: topAnchor),
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
||||
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupActions() {
|
||||
textField.delegate = self
|
||||
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||
textField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
|
||||
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||||
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||||
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func updateNavigationEnabled(_ enabled: Bool) {
|
||||
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
|
||||
previousButton.isEnabled = enabled
|
||||
previousButton.alpha = enabled ? 1 : 0.45
|
||||
nextButton.isEnabled = enabled
|
||||
nextButton.alpha = enabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
private func showInitialState() {
|
||||
tableView.isHidden = true
|
||||
emptyStateLabel.isHidden = false
|
||||
emptyStateLabel.text = "输入关键词开始搜索"
|
||||
}
|
||||
|
||||
private func scrollToCurrentMatchIfNeeded() {
|
||||
guard let currentMatchIndex else { return }
|
||||
for (sectionIndex, section) in searchSections.enumerated() {
|
||||
if let rowIndex = section.items.firstIndex(where: { $0.matchIndex == currentMatchIndex }) {
|
||||
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func item(at indexPath: IndexPath) -> RDEPUBReaderSearchSection.Item {
|
||||
searchSections[indexPath.section].items[indexPath.row]
|
||||
}
|
||||
|
||||
@objc private func textFieldDidReturn() {
|
||||
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||||
let keyword = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !keyword.isEmpty else { return }
|
||||
onSearchSubmit?(keyword)
|
||||
textField.resignFirstResponder()
|
||||
}
|
||||
|
||||
@objc private func textFieldDidChange() {
|
||||
guard textField.markedTextRange == nil else { return }
|
||||
onSearchTextChanged?(textField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func previousAction() {
|
||||
onSearchPrevious?()
|
||||
}
|
||||
@@ -249,3 +403,197 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
onClose?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var rd_searchIsDarkBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
|
||||
return false
|
||||
}
|
||||
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
||||
return luminance < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
searchSections.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
searchSections[section].items.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
)
|
||||
|
||||
guard let cell = cell as? RDEPUBReaderSearchResultCell else {
|
||||
return cell
|
||||
}
|
||||
|
||||
let item = item(at: indexPath)
|
||||
cell.configure(
|
||||
previewText: item.previewText,
|
||||
keyword: keyword,
|
||||
isCurrent: item.isCurrent
|
||||
)
|
||||
cell.accessibilityIdentifier = "epub.reader.search.result.\(item.matchIndex)"
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let container = UIView()
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
||||
label.textColor = UIColor(white: 0.96, alpha: 1)
|
||||
label.text = searchSections[section].title
|
||||
label.numberOfLines = 2
|
||||
container.addSubview(label)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
|
||||
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
|
||||
label.topAnchor.constraint(equalTo: container.topAnchor, constant: 4),
|
||||
label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
40
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
116
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
onSelectMatch?(item(at: indexPath).matchIndex)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBReaderSearchBarView: UITextFieldDelegate {
|
||||
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onSearchTextChanged?("")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDEPUBReaderSearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "RDEPUBReaderSearchResultCell"
|
||||
|
||||
static var cardBackgroundColor = UIColor(white: 0.12, alpha: 1)
|
||||
static var activeCardBackgroundColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
||||
static var primaryTextColor = UIColor(white: 0.96, alpha: 1)
|
||||
static var highlightTextColor = UIColor.systemBlue
|
||||
static var activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
||||
|
||||
private let cardView = UIView()
|
||||
private let previewLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
previewLabel.attributedText = nil
|
||||
}
|
||||
|
||||
func configure(previewText: String, keyword: String, isCurrent: Bool) {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
cardView.backgroundColor = isCurrent ? Self.activeCardBackgroundColor : Self.cardBackgroundColor
|
||||
previewLabel.attributedText = attributedPreviewText(
|
||||
previewText,
|
||||
keyword: keyword,
|
||||
isCurrent: isCurrent
|
||||
)
|
||||
}
|
||||
|
||||
private func setupSubviews() {
|
||||
cardView.translatesAutoresizingMaskIntoConstraints = false
|
||||
previewLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(previewLabel)
|
||||
|
||||
cardView.layer.cornerRadius = 16
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
previewLabel.numberOfLines = 0
|
||||
previewLabel.font = UIFont.systemFont(ofSize: 18, weight: .regular)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
NSLayoutConstraint.activate([
|
||||
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10),
|
||||
|
||||
previewLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16),
|
||||
previewLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16),
|
||||
previewLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16),
|
||||
previewLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16)
|
||||
])
|
||||
}
|
||||
|
||||
private func attributedPreviewText(_ previewText: String, keyword: String, isCurrent: Bool) -> NSAttributedString {
|
||||
let normalizedText = previewText
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.lineSpacing = 8
|
||||
|
||||
let attributed = NSMutableAttributedString(
|
||||
string: normalizedText,
|
||||
attributes: [
|
||||
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
|
||||
.foregroundColor: Self.primaryTextColor,
|
||||
.paragraphStyle: paragraphStyle
|
||||
]
|
||||
)
|
||||
|
||||
let searchKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !searchKeyword.isEmpty else { return attributed }
|
||||
|
||||
let nsText = normalizedText as NSString
|
||||
var searchRange = NSRange(location: 0, length: nsText.length)
|
||||
let highlightColor = isCurrent ? Self.activeHighlightTextColor : Self.highlightTextColor
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = nsText.range(of: searchKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
attributed.addAttribute(.foregroundColor, value: highlightColor, range: foundRange)
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
guard nextLocation < nsText.length else { break }
|
||||
searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation)
|
||||
}
|
||||
|
||||
return attributed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,5 +136,6 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
} else {
|
||||
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
|
||||
}
|
||||
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ public final class RDURLReaderController: UIViewController {
|
||||
private var demoStateTimer: Timer?
|
||||
private var lastEmittedDemoState = ""
|
||||
private var pendingSearchKeyword: String?
|
||||
private var externalLinkActivationCount = 0
|
||||
private var lastActivatedExternalURL: URL?
|
||||
private var lastReaderErrorDescription = "none"
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@@ -78,6 +81,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = bookURL.deletingPathExtension().lastPathComponent
|
||||
RDEPUBResourceURLSchemeHandler.resetDebugMetrics()
|
||||
embedReaderController()
|
||||
installDemoStateLabel()
|
||||
}
|
||||
@@ -370,6 +374,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let mapSnapshot = demoPaginationSnapshot()
|
||||
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
|
||||
let resourceMetrics = RDEPUBResourceURLSchemeHandler.debugMetricsSnapshot()
|
||||
let cacheStats = readerController?.readerContext.makeChapterSummaryDiskCache().cacheStatistics
|
||||
?? (fileCount: 0, totalBytes: 0)
|
||||
let inspectable = readerController?.configuration.allowsInspectableWebViews ?? epubConfiguration.allowsInspectableWebViews
|
||||
let state = [
|
||||
"reader=opened",
|
||||
"page=\(page)",
|
||||
@@ -389,7 +397,17 @@ public final class RDURLReaderController: UIViewController {
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)"
|
||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
|
||||
"inspectable=\(inspectable ? 1 : 0)",
|
||||
"streamedResources=\(resourceMetrics.streamedResponses)",
|
||||
"inMemoryResources=\(resourceMetrics.inMemoryResponses)",
|
||||
"resourceFailures=\(resourceMetrics.failures)",
|
||||
"cacheFiles=\(cacheStats.fileCount)",
|
||||
"cacheBytes=\(cacheStats.totalBytes)",
|
||||
"externalLinks=\(externalLinkActivationCount)",
|
||||
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
|
||||
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
|
||||
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
|
||||
].joined(separator: " ")
|
||||
demoStateLabel.text = state
|
||||
if let logPrefix {
|
||||
@@ -405,6 +423,25 @@ public final class RDURLReaderController: UIViewController {
|
||||
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
|
||||
}
|
||||
|
||||
private func encodedDemoField(_ value: String?) -> String {
|
||||
guard let value, !value.isEmpty else { return "nil" }
|
||||
return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value.replacingOccurrences(of: " ", with: "_")
|
||||
}
|
||||
|
||||
private func currentSearchMatchText() -> String {
|
||||
guard let match = readerController?.searchState?.currentMatch,
|
||||
let rangeLocation = match.rangeLocation,
|
||||
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
|
||||
return "none"
|
||||
}
|
||||
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
|
||||
guard nsRange.location >= 0,
|
||||
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
|
||||
return "none"
|
||||
}
|
||||
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
|
||||
}
|
||||
|
||||
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
|
||||
guard let readerController else {
|
||||
return ("unavailable", "none", 0, 0, 0)
|
||||
@@ -486,6 +523,17 @@ extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {
|
||||
emitDemoState(prefix: "bookmarks=\(bookmarks.count)")
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {
|
||||
externalLinkActivationCount += 1
|
||||
lastActivatedExternalURL = url
|
||||
emitDemoState(prefix: "externalLinks=\(externalLinkActivationCount)")
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didFailWithError error: Error) {
|
||||
lastReaderErrorDescription = String(describing: error)
|
||||
emitDemoState(prefix: "lastError=\(lastReaderErrorDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
|
||||
+79
-13
@@ -34,8 +34,28 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||
// 文件不存在属于正常缓存未命中,不报错
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ decode error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
@@ -75,32 +95,78 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
isCacheComplete(keys: keys)
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
removeFiles(matching: { _ in true })
|
||||
}
|
||||
|
||||
/// 清空指定书籍的所有缓存文件
|
||||
func removeAll(forBookID bookID: String) {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||||
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||||
}
|
||||
|
||||
/// 清空指定渲染签名下的所有缓存文件
|
||||
func removeAll(forRenderSignature renderSignature: String) {
|
||||
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||||
removeFiles { $0.contains(renderPrefix) }
|
||||
}
|
||||
|
||||
/// 缓存统计信息
|
||||
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return (0, 0)
|
||||
}
|
||||
var count = 0
|
||||
var totalBytes: Int64 = 0
|
||||
for fileURL in files where fileURL.pathExtension == "json" {
|
||||
count += 1
|
||||
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalBytes += Int64(size)
|
||||
}
|
||||
}
|
||||
return (count, totalBytes)
|
||||
}
|
||||
|
||||
// MARK: - key -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data = try? JSONEncoder().encode(summary)
|
||||
try? data?.write(to: fileURL)
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
do {
|
||||
let data = try JSONEncoder().encode(summary)
|
||||
try data.write(to: tmpURL)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ write error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
#endif
|
||||
try? fileManager.removeItem(at: tmpURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFiles(matching predicate: (String) -> Bool) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||||
for fileURL in files where fileURL.pathExtension == "json" && predicate(fileURL.lastPathComponent) {
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -52,7 +52,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.isSwitchingChapter = false
|
||||
self.buildSnapshotAroundCurrent(chapter: chapter)
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
#endif
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
@@ -76,7 +78,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
|
||||
@@ -86,7 +90,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return store.chapterData(for: spineIndex)
|
||||
}
|
||||
let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
|
||||
#endif
|
||||
currentSnapshot = snapshot
|
||||
isApplyingSnapshot = true
|
||||
onSnapshotChanged?(snapshot)
|
||||
@@ -240,14 +246,18 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
#endif
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
if self.currentSnapshot == nil {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBLocationConverter {
|
||||
|
||||
// MARK: - 主路径:先构建目标章,拿到真实长度后再精确转换
|
||||
|
||||
/// 旧版 RDEPUBLocation -> 新版 RDEPUBChapterLocation
|
||||
/// 主迁移路径:要求先构建目标章,用真实 chapterLength 做精确转换
|
||||
/// 仅在无法获取章节长度时才降级到粗估 fallback
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
chapterLengthProvider: ((Int) -> Int?)? = nil
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 1. 从 href 找到 spineIndex
|
||||
guard let spineItem = publication.spine.first(where: {
|
||||
$0.href == location.href || $0.href.contains(location.href)
|
||||
}) else { return nil }
|
||||
|
||||
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
|
||||
|
||||
// 2. 优先用 fragmentID 定位(最精确,不受 progression 精度影响)
|
||||
if let fragmentID = location.fragment {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: 0, // fragmentID 由 chapterOffsetMap 精确解析
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: location.progression
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 有 chapterLength 时做精确转换
|
||||
if let provider = chapterLengthProvider,
|
||||
let chapterLength = provider(spineIndex), chapterLength > 0 {
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback:无法获取章节长度时的粗估(仅作临时降级)
|
||||
let estimatedOffset = Int(location.progression * 10000)
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: estimatedOffset,
|
||||
fragmentID: nil,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 1 // 标记为降级结果,后续可被精确值覆盖
|
||||
)
|
||||
}
|
||||
|
||||
/// 精确转换:已知章节实际长度
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBChapterLocation? {
|
||||
let offset = Int(location.progression * Double(chapterLength))
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: location.fragment,
|
||||
progressionInChapter: location.progression,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已构建的 RDEPUBRuntimeChapter 做精确转换(推荐迁移路径)
|
||||
static func convert(
|
||||
legacy location: RDEPUBLocation,
|
||||
chapter: RDEPUBRuntimeChapter
|
||||
) -> RDEPUBChapterLocation? {
|
||||
// 优先用 fragmentID
|
||||
if let fragmentID = location.fragment,
|
||||
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
|
||||
return RDEPUBChapterLocation(
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterOffset: fragmentOffset,
|
||||
fragmentID: fragmentID,
|
||||
progressionInChapter: nil,
|
||||
schemaVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
// 用 progression + 真实长度
|
||||
let chapterLength = chapter.typesetAttributedString.length
|
||||
return convert(
|
||||
legacy: location,
|
||||
spineIndex: chapter.spineIndex,
|
||||
chapterLength: chapterLength
|
||||
)
|
||||
}
|
||||
|
||||
/// 新版 -> 旧版(兼容外部接口)
|
||||
static func toLegacy(
|
||||
chapterLocation: RDEPUBChapterLocation,
|
||||
href: String,
|
||||
chapterLength: Int
|
||||
) -> RDEPUBLocation {
|
||||
let progression = chapterLength > 0
|
||||
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
|
||||
: 0
|
||||
return RDEPUBLocation(
|
||||
href: href,
|
||||
progression: min(max(progression, 0), 1),
|
||||
fragment: chapterLocation.fragmentID
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,6 @@ final class RDEPUBPageCountCache {
|
||||
}
|
||||
}
|
||||
|
||||
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
|
||||
}
|
||||
|
||||
func remove(forSpineIndex spineIndex: Int) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
+39
-10
@@ -32,14 +32,34 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
if let selection, !selection.isEmpty {
|
||||
applySelectionState(.selected(selection))
|
||||
} else {
|
||||
applySelectionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一选区状态变更入口。
|
||||
/// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。
|
||||
/// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。
|
||||
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
if controller.currentSelection != nil,
|
||||
controller.readerView.isShowToolView == false {
|
||||
controller.readerView.tapCenter()
|
||||
context.selectionState = state
|
||||
switch state {
|
||||
case .idle:
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: nil)
|
||||
case .selecting:
|
||||
break
|
||||
case .selected(let selection):
|
||||
if controller.readerView.isShowToolView == false {
|
||||
controller.readerView.tapCenter()
|
||||
}
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: selection)
|
||||
case .committingAction:
|
||||
break
|
||||
}
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||||
}
|
||||
|
||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||
@@ -139,11 +159,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
/// 跳转到指定高亮所在位置。
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
return false
|
||||
}
|
||||
return controller.restoreReadingLocation(highlight.location, animated: animated)
|
||||
return navigate(to: highlight, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除所有高亮标记。
|
||||
@@ -196,9 +215,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
)
|
||||
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
|
||||
guard let controller = self?.controller else { return }
|
||||
highlightsController?.dismiss(animated: true) {
|
||||
controller.go(to: highlight.location)
|
||||
_ = self?.navigate(to: highlight, animated: true)
|
||||
}
|
||||
}
|
||||
highlightsController.onUpdateHighlight = { [weak self] highlight in
|
||||
@@ -371,6 +389,17 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||
guard let controller else { return false }
|
||||
let navigationTarget = scopedHighlight(highlight) ?? highlight
|
||||
return controller.restoreReadingLocation(
|
||||
navigationTarget.location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: navigationTarget.rangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
private func persistHighlightsAndRefreshContent() {
|
||||
guard let controller else { return }
|
||||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||||
|
||||
@@ -23,7 +23,9 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
#if DEBUG
|
||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
@@ -41,9 +43,13 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
}
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
#if DEBUG
|
||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||
#endif
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
#if DEBUG
|
||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||
#endif
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
canToggleBookmark: controller.currentBookIdentifier != nil,
|
||||
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
||||
canShowBookmarks: !controller.activeBookmarks.isEmpty,
|
||||
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil,
|
||||
canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection,
|
||||
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
|
||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||
allowsHighlights: controller.configuration.allowsHighlights,
|
||||
|
||||
@@ -59,8 +59,19 @@ final class RDEPUBReaderContext {
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
/// 后台元数据解析使用的并发数。
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
/// 当前用户文本选区。
|
||||
var currentSelection: RDEPUBSelection?
|
||||
/// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
if let newValue, !newValue.isEmpty {
|
||||
selectionState = .selected(newValue)
|
||||
} else {
|
||||
selectionState = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一选区状态模型,收口所有选区相关状态变更。
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
// MARK: - 控制器状态(从 controller 下沉)
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
animated: Bool = false,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return false }
|
||||
guard let targetPageNumber = controller.pageNumber(for: location) else {
|
||||
@@ -32,13 +36,15 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else if context.textBook == nil {
|
||||
_ = context.readingSession?.queueNavigation(
|
||||
to: location,
|
||||
relativeToSpineIndex: nil,
|
||||
bookIdentifier: context.currentBookIdentifier
|
||||
bookIdentifier: context.currentBookIdentifier,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
} else {
|
||||
context.readingSession?.transition(to: .jumping)
|
||||
|
||||
@@ -33,10 +33,14 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
#endif
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
||||
#endif
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@@ -48,7 +52,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=fixed-layout")
|
||||
#endif
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
@@ -59,7 +65,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
#if DEBUG
|
||||
print("[EPUB][Pagination] path=web-paginator")
|
||||
#endif
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
|
||||
@@ -230,6 +230,12 @@ final class RDEPUBReaderRuntime {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
/// 跳转到指定搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
searchCoordinator.selectSearchMatch(at: index)
|
||||
}
|
||||
|
||||
/// 清除搜索状态
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
@@ -365,8 +371,16 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
/// 恢复到指定阅读位置
|
||||
@discardableResult
|
||||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
animated: Bool = false,
|
||||
targetHighlightRangeInfo: String? = nil
|
||||
) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(
|
||||
location,
|
||||
animated: animated,
|
||||
targetHighlightRangeInfo: targetHighlightRangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置
|
||||
|
||||
@@ -51,6 +51,21 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
/// 跳转到指定索引的搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState,
|
||||
searchState.matches.indices.contains(index) else {
|
||||
return false
|
||||
}
|
||||
|
||||
searchState.currentMatchIndex = index
|
||||
controller.searchState = searchState
|
||||
notifySearchStateChanged()
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
/// 清除搜索状态并刷新当前可见内容
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
@@ -109,12 +124,109 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
||||
}
|
||||
if controller.readerContext.bookPageMap != nil, controller.publication != nil {
|
||||
return resolvedOnDemandSearchMatches(for: keyword)
|
||||
}
|
||||
if let parser = controller.parser, let publication = controller.publication {
|
||||
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private func resolvedOnDemandSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
|
||||
guard let controller,
|
||||
let publication = controller.publication else {
|
||||
return []
|
||||
}
|
||||
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for spineIndex in buildableSpineIndices {
|
||||
guard let chapter = try? controller.runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: controller.runtime.chapterRuntimeStore
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
||||
let source = chapter.typesetAttributedString.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { continue }
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func makeChapterData(
|
||||
from runtimeChapter: RDEPUBRuntimeChapter,
|
||||
chapterIndex: Int
|
||||
) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: runtimeChapter.spineIndex,
|
||||
href: runtimeChapter.href,
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func advanceSearch(by delta: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
||||
@@ -162,6 +274,14 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
guard let controller else { return nil }
|
||||
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
if let exactPageNumber = exactPageNumber(
|
||||
for: searchMatch,
|
||||
in: chapterData,
|
||||
keyword: controller.searchState?.keyword
|
||||
) {
|
||||
return exactPageNumber
|
||||
}
|
||||
|
||||
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
||||
return pageNumber
|
||||
}
|
||||
@@ -194,4 +314,39 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
bookIdentifier: controller.currentBookIdentifier
|
||||
).map { $0 + 1 }
|
||||
}
|
||||
|
||||
private func exactPageNumber(
|
||||
for searchMatch: RDEPUBSearchMatch,
|
||||
in chapterData: RDEPUBChapterData,
|
||||
keyword: String?
|
||||
) -> Int? {
|
||||
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalizedKeyword.isEmpty else { return nil }
|
||||
|
||||
let source = chapterData.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else { return nil }
|
||||
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else { break }
|
||||
|
||||
if localMatchIndex == searchMatch.localMatchIndex,
|
||||
let page = chapterData.page(containing: foundRange.location) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// RDEPUBSelectionState.swift
|
||||
// 统一选区状态模型
|
||||
// 收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态
|
||||
// 所带来的维护成本和时序问题。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 统一选区状态枚举
|
||||
/// 替代原先散落在 view/controller/coordinator 的 `currentSelection != nil` 判断
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
/// 无选区
|
||||
case idle
|
||||
/// 用户正在拖拽选区(长按手势已开始,尚未松手)
|
||||
case selecting(anchor: Int)
|
||||
/// 选区已完成(用户松手,有有效文本)
|
||||
case selected(RDEPUBSelection)
|
||||
/// 正在执行选区菜单动作(拷贝/高亮/批注),动作完成后回到 idle
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||
|
||||
/// 当前是否有有效选区(selecting / selected / committingAction 均视为有选区)
|
||||
var hasSelection: Bool {
|
||||
switch self {
|
||||
case .idle:
|
||||
return false
|
||||
case .selecting, .selected, .committingAction:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前选区数据(如有)
|
||||
var selection: RDEPUBSelection? {
|
||||
switch self {
|
||||
case .idle, .selecting:
|
||||
return nil
|
||||
case .selected(let selection), .committingAction(let selection, _):
|
||||
return selection
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,17 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。
|
||||
public var metadataParsingConcurrency: Int
|
||||
|
||||
// MARK: 安全策略
|
||||
|
||||
/// 允许直接打开的外部 URL scheme 集合,默认仅允许 https
|
||||
public var allowedExternalURLSchemes: Set<String>
|
||||
/// 打开外部链接前是否需要用户确认,默认 true
|
||||
public var requiresExternalLinkConfirmation: Bool
|
||||
/// 是否允许正文和离屏分页 WebView 开启 inspectable,默认 false
|
||||
public var allowsInspectableWebViews: Bool
|
||||
/// 是否允许输出完整 WebView 消息体日志,默认 false
|
||||
public var enablesVerboseWebViewLogging: Bool
|
||||
|
||||
// MARK: 初始化
|
||||
|
||||
/// 创建阅读器配置,所有参数均提供合理的默认值
|
||||
@@ -136,6 +147,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
/// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇)
|
||||
/// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数
|
||||
/// - allowedExternalURLSchemes: 允许直接打开的外部 URL scheme 集合,默认仅 https
|
||||
/// - requiresExternalLinkConfirmation: 打开外部链接前是否需要确认,默认 true
|
||||
/// - allowsInspectableWebViews: 是否允许开启 inspectable,默认 false
|
||||
/// - enablesVerboseWebViewLogging: 是否允许输出完整 WebView 消息体日志,默认 false
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
@@ -156,7 +171,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
|
||||
onDemandChapterWindowSize: Int = 3,
|
||||
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
|
||||
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
|
||||
allowedExternalURLSchemes: Set<String> = ["https"],
|
||||
requiresExternalLinkConfirmation: Bool = true,
|
||||
allowsInspectableWebViews: Bool = false,
|
||||
enablesVerboseWebViewLogging: Bool = false
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
@@ -178,6 +197,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
|
||||
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
|
||||
self.allowedExternalURLSchemes = allowedExternalURLSchemes
|
||||
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
|
||||
self.allowsInspectableWebViews = allowsInspectableWebViews
|
||||
self.enablesVerboseWebViewLogging = enablesVerboseWebViewLogging
|
||||
}
|
||||
|
||||
/// 默认配置实例,使用所有参数的默认值
|
||||
|
||||
@@ -239,6 +239,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||||
updateThemeSelection(selectedPreset)
|
||||
updateControlAccessibilityValues()
|
||||
}
|
||||
|
||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||
@@ -279,9 +280,17 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let isSelected = button.tag == preset.rawValue
|
||||
button.layer.borderWidth = isSelected ? 2 : 1
|
||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
||||
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateControlAccessibilityValues() {
|
||||
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
||||
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
||||
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
||||
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
||||
}
|
||||
|
||||
@objc private func doneAction() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
@@ -312,6 +321,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let choice = choices[index]
|
||||
guard choice != currentConfiguration.fontChoice else { return }
|
||||
currentConfiguration.fontChoice = choice
|
||||
updateControlAccessibilityValues()
|
||||
onFontChoiceChange?(choice)
|
||||
}
|
||||
|
||||
@@ -319,12 +329,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||
let value = lineHeightValues[index]
|
||||
currentConfiguration.lineHeightMultiple = value
|
||||
updateControlAccessibilityValues()
|
||||
onLineHeightChange?(value)
|
||||
}
|
||||
|
||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
||||
currentConfiguration.numberOfColumns = numberOfColumns
|
||||
updateControlAccessibilityValues()
|
||||
onColumnCountChange?(numberOfColumns)
|
||||
}
|
||||
|
||||
@@ -339,6 +351,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
displayType = .pageCurl
|
||||
}
|
||||
currentConfiguration.displayType = displayType
|
||||
updateControlAccessibilityValues()
|
||||
onDisplayTypeChange?(displayType)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import UIKit
|
||||
|
||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
|
||||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - highlights: 高亮批注数组
|
||||
@@ -58,8 +61,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
@@ -72,7 +73,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
@@ -97,9 +98,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
if let searchState {
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
|
||||
for match in searchState.matches {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
@@ -113,7 +111,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||
let color = isActive ? activeColor : normalColor
|
||||
let color = isActive ? activeSearchColor : normalSearchColor
|
||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didActivateAttachmentText text: String,
|
||||
sourceRect: CGRect
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
@@ -33,6 +38,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
@@ -188,6 +194,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
@@ -244,14 +251,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
highlights: [],
|
||||
searchState: searchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
updateAccessibilityDecorationSummary()
|
||||
|
||||
@@ -262,6 +261,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentSearchState = nil
|
||||
panGestureRecognizer.isEnabled = false
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
@@ -487,6 +487,17 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||
overlayView.updateSnapshot(interactionController.snapshot)
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
|
||||
if let page = currentPage {
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
highlights: [],
|
||||
searchState: currentSearchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -539,6 +550,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
if let attachmentText = attachmentText(at: point),
|
||||
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
||||
delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect)
|
||||
return
|
||||
}
|
||||
guard let highlight = highlight(at: point),
|
||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||
return
|
||||
@@ -621,6 +637,41 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return overlayView.convert(fallbackRect, to: self)
|
||||
}
|
||||
|
||||
private func attachmentText(at point: CGPoint) -> String? {
|
||||
guard let page = currentPage else { return nil }
|
||||
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
|
||||
return nil
|
||||
}
|
||||
guard page.chapterContent.length > attachmentRange.location else {
|
||||
return nil
|
||||
}
|
||||
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
if let textAttachment = attachment as? DTTextAttachment,
|
||||
let altText = (textAttachment.attributes["alt"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!altText.isEmpty {
|
||||
return altText
|
||||
}
|
||||
#endif
|
||||
|
||||
if let fileAttachment = attachment as? NSTextAttachment,
|
||||
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!altText.isEmpty {
|
||||
return altText
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
|
||||
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
|
||||
return overlayView.convert(attachmentRect, to: self)
|
||||
}
|
||||
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
|
||||
return overlayView.convert(fallbackRect, to: self)
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
return selectionController.isSelecting
|
||||
@@ -633,7 +684,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return true
|
||||
}
|
||||
let point = tapGestureRecognizer.location(in: overlayView)
|
||||
return highlight(at: point) != nil
|
||||
return attachmentText(at: point) != nil || highlight(at: point) != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -140,12 +140,25 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
let totalLength = max(page.chapterContent.length - 1, 1)
|
||||
let globalStart = absoluteRange.location
|
||||
let globalEnd = absoluteRange.location + absoluteRange.length
|
||||
let startAnchor = RDEPUBTextAnchor(
|
||||
fileIndex: page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: globalStart
|
||||
)
|
||||
let endAnchor = RDEPUBTextAnchor(
|
||||
fileIndex: page.spineIndex,
|
||||
row: 0,
|
||||
column: 0,
|
||||
chapterOffset: globalEnd
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||||
fragment: nil
|
||||
fragment: nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
|
||||
Reference in New Issue
Block a user