feat(reader): 增强阅读器功能与 UI 测试支持

- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
This commit is contained in:
shen
2026-05-31 23:56:54 +08:00
parent 44202357c0
commit 1efb9d172f
268 changed files with 4991 additions and 44 deletions
@@ -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
}
}