feat(reader): 增强阅读器功能与 UI 测试支持
- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置 - 文本选择改为自定义手势+操作栏(拷贝/高亮/批注) - 添加 accessibilityIdentifier 支持自动化 UI 测试 - 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等 - 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读) - 新增文档:UI 自动化测试、功能开发计划、阅读器规划
This commit is contained in:
@@ -44,6 +44,7 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.bottomToolbar"
|
||||
|
||||
addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
@@ -69,8 +69,13 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
updateCurrentSelection(normalizedTextSelection(selection))
|
||||
}
|
||||
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
handleSelectionMenuAction(action, selection: currentSelection)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
) {
|
||||
let normalizedSelection = selection.flatMap(normalizedTextSelection)
|
||||
handleSelectionMenuAction(action, selection: normalizedSelection ?? currentSelection)
|
||||
contentView.clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ extension RDEPUBReaderController {
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
) -> Bool {
|
||||
oldConfiguration.fontSize != newConfiguration.fontSize ||
|
||||
oldConfiguration.fontChoice != newConfiguration.fontChoice ||
|
||||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
|
||||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
|
||||
oldConfiguration.columnGap != newConfiguration.columnGap ||
|
||||
@@ -172,7 +173,9 @@ extension RDEPUBReaderController {
|
||||
from oldConfiguration: RDEPUBReaderConfiguration,
|
||||
to newConfiguration: RDEPUBReaderConfiguration
|
||||
) -> Bool {
|
||||
oldConfiguration.theme != newConfiguration.theme
|
||||
oldConfiguration.theme != newConfiguration.theme ||
|
||||
oldConfiguration.darkImageAdjustmentEnabled != newConfiguration.darkImageAdjustmentEnabled ||
|
||||
oldConfiguration.darkImageBlendRatio != newConfiguration.darkImageBlendRatio
|
||||
}
|
||||
|
||||
func presentTableOfContents() {
|
||||
@@ -180,7 +183,6 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
|
||||
func handleBackAction() {
|
||||
print("[Debug] handleBackAction called")
|
||||
runtime.handleBackAction()
|
||||
}
|
||||
|
||||
@@ -231,4 +233,3 @@ extension RDEPUBReaderController: UIGestureRecognizerDelegate {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.topToolbar"
|
||||
self.backgroundColor = .white
|
||||
addSubview(backButton)
|
||||
addSubview(bookmarkButton)
|
||||
@@ -100,7 +101,6 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
print("[Debug] backAction fired, onBack: \(String(describing: onBack))")
|
||||
onBack?()
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ public final class RDURLReaderController: UIViewController {
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
/// 内嵌的阅读器控制器
|
||||
private var embeddedController: UIViewController?
|
||||
private let demoStateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.accessibilityIdentifier = "demo.reader.state"
|
||||
label.font = .systemFont(ofSize: 1)
|
||||
label.textColor = .clear
|
||||
label.alpha = 0.01
|
||||
label.isAccessibilityElement = true
|
||||
label.text = "reader=opening"
|
||||
return label
|
||||
}()
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@@ -56,13 +66,19 @@ public final class RDURLReaderController: UIViewController {
|
||||
view.backgroundColor = .systemBackground
|
||||
title = bookURL.deletingPathExtension().lastPathComponent
|
||||
embedReaderController()
|
||||
installDemoStateLabel()
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
/// 切换阅读器的翻页模式(Demo 用)
|
||||
/// - Parameter displayType: 目标翻页模式
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
/// 跳转到指定页码(Demo 用)
|
||||
@@ -74,7 +90,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
if moved {
|
||||
logDemoState(prefix: "page=\(pageNumber)")
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
}
|
||||
return moved
|
||||
}
|
||||
@@ -162,6 +178,8 @@ public final class RDURLReaderController: UIViewController {
|
||||
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
controller.didMove(toParent: self)
|
||||
readerController?.delegate = self
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
/// 获取内嵌的 EPUB 阅读器控制器(如果存在)
|
||||
@@ -179,6 +197,32 @@ public final class RDURLReaderController: UIViewController {
|
||||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||||
}
|
||||
|
||||
private func installDemoStateLabel() {
|
||||
view.addSubview(demoStateLabel)
|
||||
demoStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
demoStateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
demoStateLabel.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
demoStateLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||
demoStateLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||
])
|
||||
}
|
||||
|
||||
private func emitDemoState(prefix: String? = nil) {
|
||||
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
|
||||
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
|
||||
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
|
||||
let highlights = readerController?.highlights.count ?? 0
|
||||
let selection = readerController?.currentSelection == nil ? 0 : 1
|
||||
let state = "reader=opened page=\(page) display=\(display) toolbar=\(toolbar) highlights=\(highlights) selection=\(selection)"
|
||||
demoStateLabel.text = state
|
||||
if let prefix {
|
||||
logDemoState(prefix: prefix)
|
||||
} else {
|
||||
print("[ReadViewDemo] automation \(state)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算文本分页的页面尺寸。
|
||||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
||||
/// 以保持与页面展示时的内容几何一致。
|
||||
@@ -188,7 +232,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
|
||||
/// 根据当前配置生成文本渲染样式
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
|
||||
let font = epubConfiguration.fontChoice.font(ofSize: epubConfiguration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
@@ -214,6 +258,20 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {
|
||||
emitDemoState(prefix: "selection=\(selection == nil ? 0 : 1)")
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {
|
||||
emitDemoState(prefix: "highlights=\(highlights.count)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页模式扩展:提供 Demo 命令行参数值
|
||||
private extension RDReaderView.DisplayType {
|
||||
/// 返回翻页模式对应的 Demo 命令行参数字符串
|
||||
|
||||
+7
-2
@@ -24,6 +24,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
guard let controller else { return }
|
||||
controller.currentSelection = selection?.isEmpty == false ? selection : nil
|
||||
if controller.currentSelection != nil,
|
||||
controller.readerView.isShowToolView == false {
|
||||
controller.readerView.tapCenter()
|
||||
}
|
||||
controller.bottomToolView.setAddHighlightEnabled(
|
||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
||||
)
|
||||
@@ -329,8 +333,9 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
private func persistHighlightsAndRefreshContent() {
|
||||
guard let controller else { return }
|
||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||||
if let currentBookIdentifier = controller.currentBookIdentifier {
|
||||
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
|
||||
}
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
|
||||
@@ -14,7 +14,6 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
print("[Debug] onBack fired, controller: \(String(describing: self?.controller))")
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
@@ -81,6 +80,9 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
settingsController.onFontSizeChange = { [weak controller] fontSize in
|
||||
controller?.updateConfiguration { $0.fontSize = fontSize }
|
||||
}
|
||||
settingsController.onFontChoiceChange = { [weak controller] fontChoice in
|
||||
controller?.updateConfiguration { $0.fontChoice = fontChoice }
|
||||
}
|
||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: configuration.fontSize)
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
|
||||
@@ -9,6 +9,51 @@ public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
// MARK: - 阅读字体
|
||||
|
||||
/// 阅读器内置字体选项。
|
||||
/// 第一版优先使用系统授权字体,后续可扩展为 bundle 内置字体注册。
|
||||
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable, Equatable {
|
||||
/// 系统默认无衬线字体
|
||||
case system
|
||||
/// 系统衬线字体
|
||||
case serif
|
||||
/// 系统圆体字体
|
||||
case rounded
|
||||
/// 系统等宽字体
|
||||
case monospaced
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .system:
|
||||
return "系统"
|
||||
case .serif:
|
||||
return "宋体"
|
||||
case .rounded:
|
||||
return "圆体"
|
||||
case .monospaced:
|
||||
return "等宽"
|
||||
}
|
||||
}
|
||||
|
||||
public func font(ofSize size: CGFloat) -> UIFont {
|
||||
switch self {
|
||||
case .system:
|
||||
return UIFont.systemFont(ofSize: size)
|
||||
case .serif:
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
.withDesign(.serif) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
case .rounded:
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
.withDesign(.rounded) ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
case .monospaced:
|
||||
return UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 阅读器配置
|
||||
|
||||
/// EPUB 阅读器的完整配置结构体
|
||||
@@ -21,6 +66,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
/// 行距倍数,默认 1.6(即行距为字号的 1.6 倍)
|
||||
public var lineHeightMultiple: CGFloat
|
||||
/// 正文字体,默认系统字体
|
||||
public var fontChoice: RDEPUBReaderFontChoice
|
||||
/// 栏数,默认单栏
|
||||
public var numberOfColumns: Int
|
||||
/// 栏间距,默认 20pt
|
||||
@@ -50,6 +97,10 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
|
||||
/// 当前阅读主题(浅色/深色/护眼等),默认 .light
|
||||
public var theme: RDEPUBReaderTheme
|
||||
/// 暗色主题下是否柔化正文图片,降低夜间阅读刺眼感
|
||||
public var darkImageAdjustmentEnabled: Bool
|
||||
/// 暗色主题图片柔化混合比例,0 表示不处理,推荐 0.12 ~ 0.2
|
||||
public var darkImageBlendRatio: CGFloat
|
||||
/// 固定布局的适配模式:按页适配或按宽度适配
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
/// 固定布局的跨页模式:自动/单页/双页
|
||||
@@ -63,6 +114,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// - Parameters:
|
||||
/// - fontSize: 正文字号,默认 15pt
|
||||
/// - lineHeightMultiple: 行距倍数,默认 1.6
|
||||
/// - fontChoice: 正文字体,默认系统字体
|
||||
/// - displayType: 翻页模式,默认 .pageCurl
|
||||
/// - landscapeDualPageEnabled: 横屏双页,默认 true
|
||||
/// - showsTableOfContents: 显示目录入口,默认 true
|
||||
@@ -71,12 +123,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
/// - reflowableContentInsets: 流式排版内边距
|
||||
/// - fixedContentInset: 固定布局内边距
|
||||
/// - theme: 阅读主题,默认 .light
|
||||
/// - darkImageAdjustmentEnabled: 暗色主题下是否柔化正文图片
|
||||
/// - darkImageBlendRatio: 暗色主题图片柔化混合比例
|
||||
/// - fixedLayoutFit: 固定布局适配模式
|
||||
/// - fixedLayoutSpreadMode: 固定布局跨页模式
|
||||
/// - textRenderingEngine: 文本渲染引擎
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
fontChoice: RDEPUBReaderFontChoice = .system,
|
||||
numberOfColumns: Int = 1,
|
||||
columnGap: CGFloat = 20,
|
||||
displayType: RDReaderView.DisplayType = .pageCurl,
|
||||
@@ -87,12 +142,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: UIEdgeInsets = .zero,
|
||||
theme: RDEPUBReaderTheme = .light,
|
||||
darkImageAdjustmentEnabled: Bool = true,
|
||||
darkImageBlendRatio: CGFloat = 0.15,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.fontChoice = fontChoice
|
||||
self.numberOfColumns = max(1, numberOfColumns)
|
||||
self.columnGap = max(0, columnGap)
|
||||
self.displayType = displayType
|
||||
@@ -103,6 +161,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.theme = theme
|
||||
self.darkImageAdjustmentEnabled = darkImageAdjustmentEnabled
|
||||
self.darkImageBlendRatio = max(0, min(0.35, darkImageBlendRatio))
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
|
||||
@@ -110,6 +110,8 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
public var brightness: CGFloat?
|
||||
/// 用户选择的正文字号(pt),nil 表示使用默认值
|
||||
public var fontSize: CGFloat?
|
||||
/// 用户选择的正文字体,nil 表示使用默认值
|
||||
public var fontChoice: RDEPUBReaderFontChoice?
|
||||
/// 用户选择的行距倍数,nil 表示使用默认值
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
/// 用户选择的栏数,nil 表示使用默认值
|
||||
@@ -123,6 +125,7 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
fontChoice: RDEPUBReaderFontChoice? = nil,
|
||||
lineHeightMultiple: CGFloat? = nil,
|
||||
numberOfColumns: Int? = nil,
|
||||
displayMode: RDEPUBReaderDisplayMode? = nil,
|
||||
@@ -130,6 +133,7 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
) {
|
||||
self.brightness = brightness
|
||||
self.fontSize = fontSize
|
||||
self.fontChoice = fontChoice
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.numberOfColumns = numberOfColumns
|
||||
self.displayMode = displayMode
|
||||
@@ -146,6 +150,9 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
if let fontSize {
|
||||
resolvedConfiguration.fontSize = fontSize
|
||||
}
|
||||
if let fontChoice {
|
||||
resolvedConfiguration.fontChoice = fontChoice
|
||||
}
|
||||
if let lineHeightMultiple {
|
||||
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
|
||||
}
|
||||
@@ -175,6 +182,7 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
RDEPUBReaderSettings(
|
||||
brightness: max(0, min(1, brightness)),
|
||||
fontSize: configuration.fontSize,
|
||||
fontChoice: configuration.fontChoice,
|
||||
lineHeightMultiple: configuration.lineHeightMultiple,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
|
||||
|
||||
@@ -12,6 +12,8 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
/// 字号变化回调(pt 值)
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
/// 字体变化回调
|
||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||
/// 行距倍数变化回调
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
/// 栏数变化回调
|
||||
@@ -66,6 +68,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
private let fontValueLabel = UILabel()
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
|
||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
||||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||||
@@ -103,11 +106,14 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func setupNavigationItems() {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
|
||||
navigationItem.rightBarButtonItem = doneItem
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
view.addSubview(scrollView)
|
||||
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -127,17 +133,26 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
brightnessSlider.minimumValue = 0
|
||||
brightnessSlider.maximumValue = 1
|
||||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||
|
||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
||||
fontValueLabel.textAlignment = .center
|
||||
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
configureFontButton(decreaseFontButton, title: "A-")
|
||||
configureFontButton(increaseFontButton, title: "A+")
|
||||
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
|
||||
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
|
||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||||
|
||||
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||||
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||||
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
|
||||
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||||
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
|
||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
||||
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||||
@@ -149,6 +164,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
button.layer.borderWidth = 1.5
|
||||
button.backgroundColor = preset.theme.contentBackgroundColor
|
||||
button.accessibilityLabel = preset.title
|
||||
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||
themeButtons.append(button)
|
||||
themeStackView.addArrangedSubview(button)
|
||||
@@ -159,6 +175,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||||
@@ -205,6 +222,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
|
||||
private func syncControls() {
|
||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
|
||||
|
||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
||||
@@ -239,7 +257,7 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
button.backgroundColor = theme.toolBackgroundColor
|
||||
}
|
||||
|
||||
[lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
||||
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
||||
control.backgroundColor = theme.toolBackgroundColor
|
||||
if #available(iOS 13.0, *) {
|
||||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
||||
@@ -288,6 +306,15 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
onFontSizeChange?(nextValue)
|
||||
}
|
||||
|
||||
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
|
||||
let choices = RDEPUBReaderFontChoice.allCases
|
||||
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
|
||||
let choice = choices[index]
|
||||
guard choice != currentConfiguration.fontChoice else { return }
|
||||
currentConfiguration.fontChoice = choice
|
||||
onFontChoiceChange?(choice)
|
||||
}
|
||||
|
||||
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
|
||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||
let value = lineHeightValues[index]
|
||||
|
||||
@@ -13,7 +13,11 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
@@ -25,8 +29,12 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
@@ -60,6 +68,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.accessibilityIdentifier = "epub.reader.selection.text"
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
return view
|
||||
@@ -78,6 +87,38 @@ final class RDEPUBTextContentView: UIView {
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var selectionLongPressGesture: UILongPressGestureRecognizer = {
|
||||
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
gesture.minimumPressDuration = 0.4
|
||||
return gesture
|
||||
}()
|
||||
|
||||
private lazy var selectionTapGesture: UITapGestureRecognizer = {
|
||||
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
gesture.numberOfTapsRequired = 1
|
||||
gesture.require(toFail: selectionLongPressGesture)
|
||||
return gesture
|
||||
}()
|
||||
|
||||
private lazy var selectionActionBar: UIStackView = {
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
stack.axis = .horizontal
|
||||
stack.alignment = .fill
|
||||
stack.distribution = .fillEqually
|
||||
stack.spacing = 1
|
||||
stack.backgroundColor = UIColor(white: 0.12, alpha: 0.96)
|
||||
stack.layer.cornerRadius = 10
|
||||
stack.layer.masksToBounds = true
|
||||
stack.layer.zPosition = 100
|
||||
stack.isHidden = true
|
||||
stack.accessibilityIdentifier = "epub.reader.selection.menu"
|
||||
return stack
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
@@ -88,23 +129,30 @@ final class RDEPUBTextContentView: UIView {
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(selectionActionBar)
|
||||
textView.delegate = selectionController
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
self.delegate?.textContentView(
|
||||
self,
|
||||
didRequestSelectionAction: action,
|
||||
selection: self.resolvedCurrentSelection()
|
||||
)
|
||||
}
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
if let selection {
|
||||
self.currentSelection = selection
|
||||
}
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.4
|
||||
addGestureRecognizer(longPress)
|
||||
addGestureRecognizer(selectionLongPressGesture)
|
||||
addGestureRecognizer(selectionTapGesture)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
if #available(iOS 16.0, *) {
|
||||
addInteraction(UIEditMenuInteraction(delegate: self))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@@ -113,6 +161,21 @@ final class RDEPUBTextContentView: UIView {
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return self
|
||||
default:
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
}
|
||||
#else
|
||||
return super.target(forAction: action, withSender: sender)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
@@ -147,6 +210,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
updateSelectionActionBarFrame()
|
||||
}
|
||||
|
||||
func configure(
|
||||
@@ -158,6 +222,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
@@ -191,7 +258,10 @@ final class RDEPUBTextContentView: UIView {
|
||||
)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = normalizedPageContent(from: page)
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(
|
||||
normalizedPageContent(from: page),
|
||||
configuration: configuration
|
||||
)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
@@ -202,15 +272,19 @@ final class RDEPUBTextContentView: UIView {
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
textView.attributedText = nil
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
updateSelectionInteractionMode(usingNativeTextSelection: true)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
updateSelectionInteractionMode(usingNativeTextSelection: false)
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
@@ -237,6 +311,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
selectionController.clearSelection(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
@@ -260,6 +337,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
selectionController.handleTap(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
@@ -268,19 +348,75 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy)
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy, selection: resolvedCurrentSelection())
|
||||
}
|
||||
|
||||
@objc private func rd_highlight(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight)
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight, selection: resolvedCurrentSelection())
|
||||
}
|
||||
|
||||
@objc private func rd_annotate(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate)
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate, selection: resolvedCurrentSelection())
|
||||
}
|
||||
|
||||
private func resolvedCurrentSelection() -> RDEPUBSelection? {
|
||||
currentSelection ?? menuSelection ?? selectionFromOverlayRange()
|
||||
}
|
||||
|
||||
private func selectionFromOverlayRange() -> RDEPUBSelection? {
|
||||
guard let page = currentPage,
|
||||
let range = overlayView.selectionRange,
|
||||
range.length > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let source = page.chapterContent.string as NSString
|
||||
let safeRange = NSIntersectionRange(
|
||||
range,
|
||||
NSRange(location: 0, length: page.chapterContent.length)
|
||||
)
|
||||
guard safeRange.length > 0 else { return nil }
|
||||
|
||||
let selectedText = source.substring(with: safeRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else { return nil }
|
||||
|
||||
let chapterLength = max(page.chapterContent.length - 1, 1)
|
||||
let chapterStart = max(safeRange.location, 0)
|
||||
let chapterEnd = max(chapterStart + safeRange.length - 1, chapterStart)
|
||||
return RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(chapterStart) / Double(chapterLength),
|
||||
lastProgression: Double(chapterEnd) / Double(chapterLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(
|
||||
href: page.href,
|
||||
start: safeRange.location,
|
||||
end: safeRange.location + safeRange.length
|
||||
).jsonString()
|
||||
)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
#if canImport(DTCoreText)
|
||||
guard selectionLongPressGesture.isEnabled else { return }
|
||||
menuSelection = resolvedCurrentSelection()
|
||||
guard menuSelection != nil else { return }
|
||||
if showSelectionActionBarIfNeeded() {
|
||||
return
|
||||
}
|
||||
if #available(iOS 16.0, *),
|
||||
let editMenuInteraction = interactions.compactMap({ $0 as? UIEditMenuInteraction }).first,
|
||||
let targetRect = currentSelectionMenuTargetRect() {
|
||||
becomeFirstResponder()
|
||||
let sourcePoint = CGPoint(x: targetRect.midX, y: targetRect.midY)
|
||||
editMenuInteraction.presentEditMenu(
|
||||
with: UIEditMenuConfiguration(identifier: nil, sourcePoint: sourcePoint)
|
||||
)
|
||||
return
|
||||
}
|
||||
selectionController.showSelectionMenuIfNeeded(
|
||||
in: self,
|
||||
overlayView: overlayView,
|
||||
@@ -292,6 +428,64 @@ final class RDEPUBTextContentView: UIView {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func selectionMenuButton(title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)
|
||||
button.backgroundColor = .clear
|
||||
button.accessibilityLabel = title
|
||||
button.accessibilityIdentifier = "epub.reader.selection.\(title)"
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
private func showSelectionActionBarIfNeeded() -> Bool {
|
||||
guard currentSelectionMenuTargetRect() != nil else { return false }
|
||||
selectionActionBar.isHidden = false
|
||||
updateSelectionActionBarFrame()
|
||||
bringSubviewToFront(selectionActionBar)
|
||||
return true
|
||||
}
|
||||
|
||||
private func hideSelectionActionBar() {
|
||||
selectionActionBar.isHidden = true
|
||||
}
|
||||
|
||||
private func updateSelectionActionBarFrame() {
|
||||
guard !selectionActionBar.isHidden,
|
||||
let targetRect = currentSelectionMenuTargetRect() else {
|
||||
return
|
||||
}
|
||||
|
||||
let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
let width = max(fittingSize.width, 168)
|
||||
let height = max(fittingSize.height, 42)
|
||||
let horizontalPadding: CGFloat = 12
|
||||
let x = min(
|
||||
max(targetRect.midX - width / 2, horizontalPadding),
|
||||
max(horizontalPadding, bounds.width - width - horizontalPadding)
|
||||
)
|
||||
let preferredY = targetRect.minY - height - 8
|
||||
let y = preferredY >= 8 ? preferredY : min(targetRect.maxY + 8, bounds.height - height - 8)
|
||||
selectionActionBar.frame = CGRect(x: x, y: max(8, y), width: width, height: height)
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionMode(usingNativeTextSelection: Bool) {
|
||||
selectionLongPressGesture.isEnabled = !usingNativeTextSelection
|
||||
selectionTapGesture.isEnabled = !usingNativeTextSelection
|
||||
}
|
||||
|
||||
private func currentSelectionMenuTargetRect() -> CGRect? {
|
||||
guard let range = overlayView.selectionRange,
|
||||
range.length > 0,
|
||||
let anchorRect = interactionController.menuAnchorRect(for: range) else {
|
||||
return nil
|
||||
}
|
||||
return overlayView.convert(anchorRect, to: self)
|
||||
}
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
@@ -346,6 +540,92 @@ final class RDEPUBTextContentView: UIView {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func darkImageAdjustedContentIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) -> NSMutableAttributedString {
|
||||
guard configuration.darkImageAdjustmentEnabled,
|
||||
configuration.darkImageBlendRatio > 0,
|
||||
configuration.theme.contentBackgroundColor.rd_isDarkReaderBackground else {
|
||||
return content
|
||||
}
|
||||
|
||||
let fullRange = NSRange(location: 0, length: content.length)
|
||||
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard let attachment = value as? DTImageTextAttachment,
|
||||
!isCoverAttachment(attachment),
|
||||
let image = attachment.image,
|
||||
shouldAdjustDarkImage(image) else {
|
||||
return
|
||||
}
|
||||
|
||||
let adjustedAttachment = DTImageTextAttachment()
|
||||
adjustedAttachment.image = adjustedImage(
|
||||
image,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor,
|
||||
blendRatio: configuration.darkImageBlendRatio,
|
||||
cacheKey: darkImageCacheKey(for: attachment, image: image, configuration: configuration)
|
||||
)
|
||||
adjustedAttachment.originalSize = attachment.originalSize
|
||||
adjustedAttachment.displaySize = attachment.displaySize
|
||||
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
|
||||
adjustedAttachment.contentURL = attachment.contentURL
|
||||
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
|
||||
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
|
||||
adjustedAttachment.attributes = attachment.attributes
|
||||
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
|
||||
}
|
||||
|
||||
private func shouldAdjustDarkImage(_ image: UIImage) -> Bool {
|
||||
image.size.width >= 80 && image.size.height >= 80
|
||||
}
|
||||
|
||||
private func darkImageCacheKey(
|
||||
for attachment: DTImageTextAttachment,
|
||||
image: UIImage,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) -> NSString {
|
||||
let source = attachment.contentURL?.absoluteString
|
||||
?? "\(Unmanaged.passUnretained(image).toOpaque())"
|
||||
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.ss_cssString)|\(configuration.darkImageBlendRatio)" as NSString
|
||||
}
|
||||
|
||||
private func adjustedImage(
|
||||
_ image: UIImage,
|
||||
backgroundColor: UIColor,
|
||||
blendRatio: CGFloat,
|
||||
cacheKey: NSString
|
||||
) -> UIImage {
|
||||
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
format.opaque = false
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
let adjusted = renderer.image { context in
|
||||
image.draw(in: CGRect(origin: .zero, size: image.size))
|
||||
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
|
||||
context.cgContext.setBlendMode(.sourceAtop)
|
||||
context.fill(CGRect(origin: .zero, size: image.size))
|
||||
}
|
||||
Self.darkAdjustedImageCache.setObject(adjusted, forKey: cacheKey)
|
||||
return adjusted
|
||||
}
|
||||
#endif
|
||||
|
||||
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
@@ -436,3 +716,62 @@ final class RDEPUBTextContentView: UIView {
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
menuFor configuration: UIEditMenuConfiguration,
|
||||
suggestedActions: [UIMenuElement]
|
||||
) -> UIMenu? {
|
||||
guard resolvedCurrentSelection() != nil else { return nil }
|
||||
|
||||
return UIMenu(children: [
|
||||
UIAction(title: "拷贝") { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(
|
||||
self,
|
||||
didRequestSelectionAction: .copy,
|
||||
selection: self.resolvedCurrentSelection()
|
||||
)
|
||||
},
|
||||
UIAction(title: "高亮") { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(
|
||||
self,
|
||||
didRequestSelectionAction: .highlight,
|
||||
selection: self.resolvedCurrentSelection()
|
||||
)
|
||||
},
|
||||
UIAction(title: "批注") { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(
|
||||
self,
|
||||
didRequestSelectionAction: .annotate,
|
||||
selection: self.resolvedCurrentSelection()
|
||||
)
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
func editMenuInteraction(
|
||||
_ interaction: UIEditMenuInteraction,
|
||||
targetRectFor configuration: UIEditMenuConfiguration
|
||||
) -> CGRect {
|
||||
currentSelectionMenuTargetRect() ?? bounds
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var rd_isDarkReaderBackground: 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.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
return luminance < 0.35
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user