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