feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理

- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-24 17:47:24 +08:00
co-authored by Claude
parent 7de661eb54
commit d15f20b097
59 changed files with 4522 additions and 7220 deletions
@@ -0,0 +1,92 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
enum RDEPUBDarkImageAdjuster {
private static let imageCache = NSCache<NSString, UIImage>()
#if canImport(DTCoreText)
static func adjustIfNeeded(
_ content: NSMutableAttributedString,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkBackground 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,
shouldAdjust(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: cacheKey(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 static 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 static func shouldAdjust(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private static func cacheKey(
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.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private static func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = imageCache.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))
}
imageCache.setObject(adjusted, forKey: cacheKey)
return adjusted
}
#endif
}
@@ -0,0 +1,78 @@
import UIKit
final class RDEPUBSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}
@@ -0,0 +1,241 @@
import UIKit
final class RDEPUBTextContentInteractionCoordinator: NSObject {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
struct Dependencies {
let hasRenderableContent: () -> Bool
let currentSelectionProvider: () -> RDEPUBSelection?
let isSelectionControllerSelecting: () -> Bool
let hasActiveSelection: () -> Bool
let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle?
let selectionContainsPoint: (CGPoint) -> Bool
let renderPointForGesture: (UIGestureRecognizer) -> CGPoint
let renderPointForTouch: (UITouch) -> CGPoint
let performLongPressSelection: (UILongPressGestureRecognizer) -> Void
let performPanSelection: (UIPanGestureRecognizer) -> Void
let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void
let presentLoupeAtPoint: (CGPoint) -> Void
let dismissLoupe: () -> Void
let showSelectionMenu: () -> Void
let hideSelectionMenu: () -> Void
let selectionTapSuppressionDidChange: (Bool) -> Void
let selectionPagingSuppressionDidChange: (Bool) -> Void
}
private let dependencies: Dependencies
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private(set) var interactionState: SelectionInteractionState = .idle
var isInteractionInProgress: Bool {
interactionState != .idle
}
init(dependencies: Dependencies) {
self.dependencies = dependencies
super.init()
}
func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
dependencies.performLongPressSelection(gesture)
switch gesture.state {
case .began:
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .changed:
updateSelectionInteractionState(.selecting)
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
case .ended:
activeSelectionHandle = nil
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
dependencies.dismissLoupe()
default:
break
}
}
func handlePan(_ gesture: UIPanGestureRecognizer) {
let point = dependencies.renderPointForGesture(gesture)
if gesture.state == .began, activeSelectionHandle == nil,
let handle = dependencies.selectionHandleAtPoint(point) {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
dependencies.presentLoupeAtPoint(point)
}
if let activeSelectionHandle {
dependencies.adjustSelection(activeSelectionHandle, point)
dependencies.presentLoupeAtPoint(point)
} else {
dependencies.performPanSelection(gesture)
if dependencies.isSelectionControllerSelecting() {
dependencies.presentLoupeAtPoint(point)
}
}
switch gesture.state {
case .ended:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
dependencies.showSelectionMenu()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
dependencies.dismissLoupe()
default:
break
}
}
func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard interactionState == .idle,
activeSelectionHandle == nil,
dependencies.hasRenderableContent(),
let touch = touches.first else {
return
}
let point = dependencies.renderPointForTouch(touch)
guard dependencies.selectionHandleAtPoint(point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
}
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
resetSelectionPendingIfNeeded()
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer {
if dependencies.isSelectionControllerSelecting() {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
return dependencies.selectionHandleAtPoint(point) != nil
}
if gestureRecognizer is UILongPressGestureRecognizer {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForGesture(gestureRecognizer)
if dependencies.selectionHandleAtPoint(point) != nil {
return false
}
if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) {
return false
}
return true
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard dependencies.hasRenderableContent() else {
return true
}
let point = dependencies.renderPointForTouch(touch)
if let handle = dependencies.selectionHandleAtPoint(point) {
if gestureRecognizer is UIPanGestureRecognizer {
activeSelectionHandle = handle
updateSelectionInteractionState(.adjustingHandle)
dependencies.hideSelectionMenu()
return true
}
if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer {
return false
}
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer
}
func reset() {
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
dependencies.dismissLoupe()
}
private func resetSelectionPendingIfNeeded() {
guard interactionState == .selectionPending else { return }
if dependencies.currentSelectionProvider() != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = interactionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
interactionState = state
let currentTapSuppressed = interactionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
if previousTapSuppressed != currentTapSuppressed {
dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
}
@@ -26,17 +26,7 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
)
}
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
enum SelectionInteractionState: Equatable {
case idle
case selectionPending
case selecting
case selectionActive
case adjustingHandle
}
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
private var contentInsets: UIEdgeInsets = .zero
@@ -48,12 +38,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private var menuSelection: RDEPUBSelection?
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
private let selectionLoupeView = RDEPUBSelectionLoupeView()
private var selectionInteractionState: SelectionInteractionState = .idle
private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
@@ -88,12 +74,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
let view = RDEPUBTextPageDecorationView()
return view
}()
private let overlayView: RDEPUBTextAnnotationOverlay = {
let view = RDEPUBTextAnnotationOverlay()
return view
}()
private let coverImageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
@@ -123,6 +109,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.isEnabled = false
gesture.delegate = self
return gesture
}()
@@ -133,6 +120,91 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return gesture
}()
private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = {
RDEPUBTextContentInteractionCoordinator(
dependencies: .init(
hasRenderableContent: { [weak self] in
self?.hasInteractiveTextContent ?? false
},
currentSelectionProvider: { [weak self] in self?.currentSelection },
isSelectionControllerSelecting: { [weak self] in
self?.selectionController.isSelecting ?? false
},
hasActiveSelection: { [weak self] in
self?.selectionController.hasActiveSelection ?? false
},
selectionHandleAtPoint: { [weak self] point in
guard let renderView = self?.coreTextRenderView else { return nil }
let handle = renderView.selectionHandle(at: point)
switch handle {
case .start:
return .start
case .end:
return .end
case nil:
return nil
}
},
selectionContainsPoint: { [weak self] point in
self?.coreTextRenderView?.selectionContains(point) ?? false
},
renderPointForGesture: { [weak self] gesture in
guard let self else { return .zero }
return gesture.location(in: self.coreTextRenderView ?? self)
},
renderPointForTouch: { [weak self] touch in
guard let self else { return .zero }
return touch.location(in: self.coreTextRenderView ?? self)
},
performLongPressSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handleLongPress(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
performPanSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handlePan(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
adjustSelection: { [weak self] handle, point in
guard let self else { return }
self.selectionController.updateSelection(
byAdjusting: handle,
at: point,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
presentLoupeAtPoint: { [weak self] point in
self?.updateSelectionLoupe(for: point)
},
dismissLoupe: { [weak self] in
self?.selectionLoupeView.dismiss()
},
showSelectionMenu: { [weak self] in
self?.showSelectionMenuIfNeeded()
},
hideSelectionMenu: { [weak self] in
self?.hideSelectionMenu()
},
selectionTapSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionTapSuppressionDidChange?(isSuppressed)
},
selectionPagingSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionPagingSuppressionDidChange?(isSuppressed)
}
)
)
}()
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
@@ -159,11 +231,16 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
} else {
self.hideSelectionMenu()
}
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.interactionStateDidChange = { [weak self] state in
self?.handleSelectionControllerStateChange(state)
guard let self else { return }
self.interactionCoordinator.selectionControllerStateDidChange(state)
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
}
selectionController.pageProvider = { [weak self] in self?.currentPage }
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
@@ -183,7 +260,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
var isSelectionInteractionInProgress: Bool {
selectionInteractionState != .idle
interactionCoordinator.isInteractionInProgress
}
var shouldAvoidReaderPageCaching: Bool {
currentPage == nil || loadingSpinner.isAnimating
}
private var hasInteractiveTextContent: Bool {
#if canImport(DTCoreText)
currentPage != nil && coreTextRenderView?.isHidden == false
#else
currentPage != nil
#endif
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
@@ -241,7 +330,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentChapterFragmentOffsets = chapterFragmentOffsets
currentSelection = nil
menuSelection = nil
updateSelectionInteractionState(.idle)
interactionCoordinator.reset()
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
@@ -257,6 +346,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
@@ -266,7 +358,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
coverImageView.image = nil
#if canImport(DTCoreText)
let displayContent = darkImageAdjustedContentIfNeeded(
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
normalizedPageContent(from: page),
configuration: configuration
)
@@ -301,6 +393,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#endif
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
@@ -317,8 +412,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
interactionCoordinator.reset()
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
@@ -340,6 +434,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
@@ -349,12 +446,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
selectionLoupeView.dismiss()
interactionCoordinator.reset()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
@@ -411,87 +508,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
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 applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
@@ -581,80 +597,38 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
selectionController.handleLongPress(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .began:
activeSelectionHandle = nil
updateSelectionInteractionState(.selecting)
hideSelectionMenu()
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .changed:
updateSelectionInteractionState(.selecting)
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
case .ended:
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
selectionLoupeView.dismiss()
default:
break
}
#endif
interactionCoordinator.handleLongPress(gesture)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
let point = gesture.location(in: coreTextRenderView ?? self)
if gesture.state == .began, activeSelectionHandle == nil {
if let renderView = coreTextRenderView,
let handle = renderView.selectionHandle(at: point) {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
updateSelectionLoupe(for: point)
}
}
interactionCoordinator.handlePan(gesture)
}
if let activeSelectionHandle, let renderView = coreTextRenderView {
selectionController.updateSelection(
byAdjusting: activeSelectionHandle,
at: point,
renderView: renderView,
interactionController: interactionController
)
updateSelectionLoupe(for: point)
} else {
selectionController.handlePan(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
if selectionController.isSelecting {
updateSelectionLoupe(for: point)
}
}
switch gesture.state {
case .ended:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
default:
break
}
#endif
private func updateSelectionPanAvailability() {
let shouldEnablePan = selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState == .adjustingHandle
guard panGestureRecognizer.isEnabled != shouldEnablePan else { return }
panGestureRecognizer.isEnabled = shouldEnablePan
}
private func updateViewInteractionAvailability() {
let shouldEnableInteraction = hasInteractiveTextContent
|| selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState != .idle
guard isUserInteractionEnabled != shouldEnableInteraction else { return }
isUserInteractionEnabled = shouldEnableInteraction
}
private func updateStaticGestureAvailability() {
let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress
let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap
guard longPressChanged || tapChanged else { return }
longPressGestureRecognizer.isEnabled = shouldEnableLongPress
tapGestureRecognizer.isEnabled = shouldEnableTap
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
@@ -728,44 +702,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
}
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
switch state {
case .idle:
if currentSelection == nil, activeSelectionHandle == nil {
updateSelectionInteractionState(.idle)
}
case .selecting:
updateSelectionInteractionState(.selecting)
case .selectionActive:
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
case .adjustingHandle:
updateSelectionInteractionState(.adjustingHandle)
}
}
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
let previousTapSuppressed = selectionInteractionState != .idle
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
selectionInteractionState = state
let currentTapSuppressed = selectionInteractionState != .idle
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
if previousTapSuppressed != currentTapSuppressed {
selectionTapSuppressionDidChange?(currentTapSuppressed)
}
if previousPagingSuppressed != currentPagingSuppressed {
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
}
}
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
switch state {
case .idle, .selectionPending, .selectionActive:
return false
case .selecting, .adjustingHandle:
return true
}
}
private var coreTextRenderView: RDEPUBTextPageRenderView? {
#if canImport(DTCoreText)
return coreTextContentView
@@ -784,7 +720,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
guard currentPage != nil else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in
@@ -857,7 +793,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
#if canImport(DTCoreText)
if selectionInteractionState == .selectionPending {
if interactionCoordinator.interactionState == .selectionPending {
return true
}
@@ -871,7 +807,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
return true
}
switch selectionInteractionState {
switch interactionCoordinator.interactionState {
case .idle:
return false
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
@@ -884,65 +820,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
#if canImport(DTCoreText)
guard selectionInteractionState == .idle,
activeSelectionHandle == nil,
currentPage != nil,
let touch = touches.first,
let renderView = coreTextRenderView else {
return
}
let point = touch.location(in: renderView)
guard renderView.selectionHandle(at: point) == nil else { return }
updateSelectionInteractionState(.selectionPending)
#endif
interactionCoordinator.touchesBegan(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
resetSelectionPendingIfNeeded()
interactionCoordinator.touchesEnded(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
resetSelectionPendingIfNeeded()
}
private func resetSelectionPendingIfNeeded() {
guard selectionInteractionState == .selectionPending else { return }
if currentSelection != nil {
updateSelectionInteractionState(.selectionActive)
} else {
updateSelectionInteractionState(.idle)
}
interactionCoordinator.touchesCancelled(touches, with: event)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
if selectionController.isSelecting {
return true
}
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
let renderView = coreTextRenderView else {
return false
}
let point = pan.location(in: renderView)
return renderView.selectionHandle(at: point) != nil
}
if gestureRecognizer === longPressGestureRecognizer {
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
let renderView = coreTextRenderView else {
return true
}
let point = longPress.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
return false
}
return true
}
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
@@ -962,123 +853,35 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
}
return currentPage != nil
}
return true
return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
guard let renderView = coreTextRenderView else {
return true
}
let point = touch.location(in: renderView)
if let handle = renderView.selectionHandle(at: point) {
if gestureRecognizer === panGestureRecognizer {
activeSelectionHandle = handle == .start ? .start : .end
updateSelectionInteractionState(.adjustingHandle)
hideSelectionMenu()
if gestureRecognizer === tapGestureRecognizer {
guard let renderView = coreTextRenderView else {
return true
}
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
let point = touch.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
}
return true
return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
}
}
private final class RDEPUBSelectionLoupeView: UIView {
private let imageView = UIImageView()
private let magnification: CGFloat = 1.45
private let captureSize = CGSize(width: 84, height: 84)
override init(frame: CGRect) {
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
isUserInteractionEnabled = false
backgroundColor = .clear
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.18
layer.shadowRadius = 10
layer.shadowOffset = CGSize(width: 0, height: 5)
imageView.frame = bounds
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
imageView.layer.cornerRadius = bounds.width / 2
imageView.layer.cornerCurve = .continuous
imageView.layer.borderWidth = 1.5
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
imageView.clipsToBounds = true
addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
let targetCenter = CGPoint(
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
y: min(
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
hostBounds.maxY - bounds.height / 2
gestureRecognizer === tapGestureRecognizer
|| interactionCoordinator.gestureRecognizer(
gestureRecognizer,
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
)
)
center = targetCenter
if isHidden {
alpha = 0
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
isHidden = false
UIView.animate(withDuration: 0.12) {
self.alpha = 1
self.transform = .identity
}
}
}
func dismiss() {
guard !isHidden else { return }
isHidden = true
alpha = 0
imageView.image = nil
}
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: captureSize)
return renderer.image { context in
let cgContext = context.cgContext
cgContext.setFillColor(UIColor.systemBackground.cgColor)
cgContext.fill(CGRect(origin: .zero, size: captureSize))
cgContext.translateBy(
x: captureSize.width / 2 - focusPoint.x * magnification,
y: captureSize.height / 2 - focusPoint.y * magnification
)
cgContext.scaleBy(x: magnification, y: magnification)
sourceView.layer.render(in: cgContext)
}
}
}
private extension UIColor {
var rd_isDarkReaderBackground: Bool {
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
}
}