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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user