- 新增 RDEPUBChapterDisplayContentCache(LRU 2,主线程限定,控制器
持有):整章显示串构建时一次性注入全章主题色与暗黑图替换,配套
共享 DTCoreTextLayouter;签名 = 章节内容对象标识 + 主题双色 +
暗黑图配置,设置/主题变化自动失效重建
- RDEPUBTextContentView.configure 改为引用共享 entry,删除每次翻页
的整章可变拷贝与按页属性写入
- 高亮/下划线改走 overlay decoration(高亮背景层、下划线前景层),
移除 applyHighlightsToContent 与 render view 的属性绘制路径,
高亮增删不再触发整章重建
- 内存警告时清空 display cache;shouldAvoidReaderPageCaching 保持
保守(P1-4 待真机数据)
验证:编译通过;高亮/批注/选区/翻页/设置 13 项 UI 回归全过。
SearchTests 10 项失败经二分确认为先存回归(5a41066 与 e4e629a 均
复现,最后已知通过为 2026-06-08),与本次改动无关,待单独排查。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1167 lines
48 KiB
Swift
1167 lines
48 KiB
Swift
import UIKit
|
|
import Foundation
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
#endif
|
|
|
|
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
|
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
|
selection: RDEPUBSelection?
|
|
)
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didActivateAttachmentText text: String,
|
|
sourceRect: CGRect,
|
|
sourcePoint: CGPoint
|
|
)
|
|
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didActivateImage image: UIImage,
|
|
sourceRect: CGRect,
|
|
altText: String?
|
|
)
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
|
sourceRect: CGRect
|
|
)
|
|
}
|
|
|
|
extension RDEPUBTextContentViewDelegate {
|
|
func textContentView(
|
|
_ contentView: RDEPUBTextContentView,
|
|
didActivateImage image: UIImage,
|
|
sourceRect: CGRect,
|
|
altText: String?
|
|
) {
|
|
// Default: no-op. Implementors can present an image viewer.
|
|
}
|
|
}
|
|
|
|
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
|
|
|
private static let pageNumberTrailingPadding: CGFloat = 4
|
|
private static let pageNumberFooterPadding: CGFloat = 8
|
|
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
|
|
|
private var contentInsets: UIEdgeInsets = .zero
|
|
|
|
private var currentPage: RDEPUBTextPage?
|
|
private var currentChapterCFIMap: RDEPUBCFIMap?
|
|
private var currentChapterFragmentOffsets: [String: Int] = [:]
|
|
|
|
private var currentSelection: RDEPUBSelection?
|
|
|
|
private var menuSelection: RDEPUBSelection?
|
|
|
|
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
|
// Stored as Any? to avoid @available on stored property restriction.
|
|
private var _editMenuInteraction: Any?
|
|
|
|
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
|
|
|
private var currentHighlights: [RDEPUBHighlight] = []
|
|
|
|
private var currentSearchState: RDEPUBSearchState?
|
|
|
|
weak var delegate: RDEPUBTextContentViewDelegate?
|
|
|
|
var selectionTapSuppressionDidChange: ((Bool) -> Void)?
|
|
|
|
var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
|
|
|
|
#if canImport(DTCoreText)
|
|
|
|
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
|
let view = RDEPUBTextPageRenderView()
|
|
view.backgroundColor = .clear
|
|
view.isOpaque = false
|
|
view.isAccessibilityElement = true
|
|
view.accessibilityTraits = .staticText
|
|
return view
|
|
}()
|
|
|
|
/// Chapter-level shared display string owned by the display cache.
|
|
/// Read-only from the page's perspective: theme color and dark-image
|
|
/// adjustment are baked in at build time; page decoration goes through
|
|
/// the overlay views.
|
|
private var coreTextDisplayContent: NSAttributedString?
|
|
|
|
private var coreTextDisplayRange: NSRange?
|
|
|
|
/// Shared chapter-level layouter (owned by the display cache); layout
|
|
/// frames are recomputed per page range and bounds change.
|
|
private var coreTextLayouter: DTCoreTextLayouter?
|
|
#endif
|
|
|
|
private let interactionController = RDEPUBPageInteractionController()
|
|
|
|
private let selectionController = RDEPUBTextSelectionController()
|
|
|
|
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
|
|
let view = RDEPUBTextPageDecorationView()
|
|
return view
|
|
}()
|
|
|
|
private let overlayView: RDEPUBTextAnnotationOverlay = {
|
|
let view = RDEPUBTextAnnotationOverlay()
|
|
return view
|
|
}()
|
|
|
|
private let coverImageView: UIImageView = {
|
|
let view = UIImageView()
|
|
view.contentMode = .scaleAspectFit
|
|
view.isHidden = true
|
|
return view
|
|
}()
|
|
|
|
private let pageNumberLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = UIFont.systemFont(ofSize: 13)
|
|
return label
|
|
}()
|
|
|
|
private let loadingSpinner: UIActivityIndicatorView = {
|
|
let spinner = UIActivityIndicatorView(style: .medium)
|
|
spinner.hidesWhenStopped = true
|
|
spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
|
|
return spinner
|
|
}()
|
|
|
|
#if DEBUG
|
|
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
|
|
private let debugContentAreaBorderView: UIView = {
|
|
let view = UIView()
|
|
view.isUserInteractionEnabled = false
|
|
view.backgroundColor = .clear
|
|
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
|
|
view.layer.borderWidth = 1
|
|
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
|
|
return view
|
|
}()
|
|
#endif
|
|
|
|
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
|
|
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
|
gesture.minimumPressDuration = 0.5
|
|
gesture.delegate = self
|
|
return gesture
|
|
}()
|
|
|
|
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
|
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
gesture.isEnabled = false
|
|
gesture.delegate = self
|
|
return gesture
|
|
}()
|
|
|
|
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
|
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
|
gesture.delegate = self
|
|
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"
|
|
addSubview(coverImageView)
|
|
#if canImport(DTCoreText)
|
|
addSubview(backgroundOverlayView)
|
|
addSubview(coreTextContentView)
|
|
#endif
|
|
addSubview(overlayView)
|
|
addSubview(pageNumberLabel)
|
|
addSubview(loadingSpinner)
|
|
addSubview(selectionLoupeView)
|
|
#if DEBUG
|
|
addSubview(debugContentAreaBorderView)
|
|
#endif
|
|
addGestureRecognizer(longPressGestureRecognizer)
|
|
addGestureRecognizer(panGestureRecognizer)
|
|
addGestureRecognizer(tapGestureRecognizer)
|
|
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
|
selectionLoupeView.isHidden = true
|
|
|
|
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
|
if #available(iOS 16.0, *) {
|
|
let interaction = UIEditMenuInteraction(delegate: self)
|
|
addInteraction(interaction)
|
|
self._editMenuInteraction = interaction
|
|
}
|
|
|
|
selectionController.onSelectionChanged = { [weak self] selection in
|
|
guard let self else { return }
|
|
self.currentSelection = selection
|
|
if let selection {
|
|
self.menuSelection = selection
|
|
} else {
|
|
self.hideSelectionMenu()
|
|
}
|
|
self.updateSelectionPanAvailability()
|
|
self.updateViewInteractionAvailability()
|
|
self.updateAccessibilityDecorationSummary()
|
|
self.delegate?.textContentView(self, didChangeSelection: selection)
|
|
}
|
|
selectionController.interactionStateDidChange = { [weak self] state in
|
|
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 }
|
|
selectionController.chapterFragmentOffsetsProvider = { [weak self] in
|
|
self?.currentChapterFragmentOffsets ?? [:]
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var canBecomeFirstResponder: Bool { true }
|
|
|
|
var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
|
|
longPressGestureRecognizer
|
|
}
|
|
|
|
var isSelectionInteractionInProgress: Bool {
|
|
interactionCoordinator.isInteractionInProgress
|
|
}
|
|
|
|
var shouldAvoidReaderPageCaching: Bool {
|
|
#if canImport(DTCoreText)
|
|
// In-context text layout keeps a chapter-length attributed copy and
|
|
// layouter alive for the visible page. Avoid reader-level page
|
|
// caching so preloading does not retain several full-chapter
|
|
// display copies at once.
|
|
// Do NOT relax this until chapter-level shared display content
|
|
// lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached
|
|
// pages would each pin their own full-chapter copy again.
|
|
return currentPage == nil
|
|
|| loadingSpinner.isAnimating
|
|
|| coreTextDisplayContent != nil
|
|
#else
|
|
currentPage == nil
|
|
|| loadingSpinner.isAnimating
|
|
#endif
|
|
}
|
|
|
|
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 {
|
|
action == #selector(rd_copy(_:))
|
|
|| action == #selector(rd_highlight(_:))
|
|
|| action == #selector(rd_annotate(_:))
|
|
}
|
|
|
|
@objc func rd_copy(_ sender: Any?) {
|
|
performSelectionAction(.copy)
|
|
}
|
|
|
|
@objc func rd_highlight(_ sender: Any?) {
|
|
performSelectionAction(.highlight)
|
|
}
|
|
|
|
@objc func rd_annotate(_ sender: Any?) {
|
|
performSelectionAction(.annotate)
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
#if canImport(DTCoreText)
|
|
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
|
|
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
|
updateCoreTextLayoutFrameIfNeeded()
|
|
#endif
|
|
overlayView.frame = bounds.inset(by: contentInsets)
|
|
coverImageView.frame = bounds.inset(by: contentInsets)
|
|
|
|
let contentRect = bounds.inset(by: contentInsets)
|
|
#if DEBUG
|
|
debugContentAreaBorderView.frame = contentRect
|
|
#endif
|
|
let labelSize = pageNumberLabel.sizeThatFits(
|
|
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
|
)
|
|
let footerOriginY = bounds.maxY - contentInsets.bottom
|
|
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
|
|
pageNumberLabel.frame = CGRect(
|
|
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
|
|
y: footerOriginY + footerVerticalInset,
|
|
width: labelSize.width,
|
|
height: labelSize.height
|
|
)
|
|
loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
}
|
|
|
|
func configure(
|
|
page: RDEPUBTextPage,
|
|
pageNumber: Int,
|
|
totalPages: Int,
|
|
configuration: RDEPUBReaderConfiguration,
|
|
chapterCFIMap: RDEPUBCFIMap? = nil,
|
|
chapterFragmentOffsets: [String: Int] = [:],
|
|
highlights: [RDEPUBHighlight] = [],
|
|
searchState: RDEPUBSearchState? = nil,
|
|
displayCache: RDEPUBChapterDisplayContentCache? = nil
|
|
) {
|
|
loadingSpinner.stopAnimating()
|
|
currentPage = page
|
|
currentChapterCFIMap = chapterCFIMap
|
|
currentChapterFragmentOffsets = chapterFragmentOffsets
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
interactionCoordinator.reset()
|
|
currentHighlights = highlights
|
|
currentSearchState = searchState
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
contentInsets = Self.safeContentInsets(
|
|
configuration: configuration,
|
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
|
)
|
|
backgroundColor = configuration.theme.contentBackgroundColor
|
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
|
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
|
|
|
if configureCoverIfNeeded(for: page) {
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.isHidden = true
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
coreTextLayouter = nil
|
|
#endif
|
|
updateStaticGestureAvailability()
|
|
updateSelectionPanAvailability()
|
|
updateViewInteractionAvailability()
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
setNeedsLayout()
|
|
return
|
|
}
|
|
|
|
coverImageView.isHidden = true
|
|
coverImageView.image = nil
|
|
|
|
#if canImport(DTCoreText)
|
|
// In-context display: lay out the full chapter string and render only
|
|
// the page's range. CTTypesetter line breaks are context-sensitive, so
|
|
// re-wrapping a page substring can break ±1 character away from the
|
|
// paginator's line ends (lone characters spilling onto the last line);
|
|
// laying out the same chapter string the paginator used cannot.
|
|
// String indices in the layout frame are therefore chapter-absolute.
|
|
// The chapter string and layouter are shared per chapter via the
|
|
// display cache; this view must not mutate the shared string.
|
|
let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
|
|
.entry(for: page, configuration: configuration)
|
|
let pageRange = NSIntersectionRange(
|
|
page.contentRange,
|
|
NSRange(location: 0, length: display.content.length)
|
|
)
|
|
coreTextContentView.isHidden = false
|
|
coreTextContentView.backgroundColor = .clear
|
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
|
coreTextDisplayContent = display.content
|
|
coreTextDisplayRange = pageRange
|
|
coreTextLayouter = display.layouter
|
|
updateCoreTextLayoutFrameIfNeeded()
|
|
#else
|
|
let selectionContent = normalizedPageContent(from: page)
|
|
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
|
selectionContent.addAttribute(
|
|
.foregroundColor,
|
|
value: configuration.theme.contentTextColor,
|
|
range: selectionRange
|
|
)
|
|
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
|
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
|
#endif
|
|
|
|
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
|
#if canImport(DTCoreText)
|
|
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
|
#endif
|
|
updateStaticGestureAvailability()
|
|
updateSelectionPanAvailability()
|
|
updateViewInteractionAvailability()
|
|
updateAccessibilityDecorationSummary()
|
|
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func configureLoading(
|
|
pageNumber: Int,
|
|
totalPages: Int,
|
|
configuration: RDEPUBReaderConfiguration
|
|
) {
|
|
currentPage = nil
|
|
currentChapterCFIMap = nil
|
|
currentChapterFragmentOffsets = [:]
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
interactionCoordinator.reset()
|
|
currentHighlights = []
|
|
currentSearchState = nil
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
contentInsets = Self.safeContentInsets(
|
|
configuration: configuration,
|
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
|
)
|
|
backgroundColor = configuration.theme.contentBackgroundColor
|
|
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
|
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
|
coverImageView.isHidden = true
|
|
coverImageView.image = nil
|
|
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.isHidden = false
|
|
coreTextContentView.backgroundColor = .clear
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
coreTextLayouter = nil
|
|
#endif
|
|
|
|
overlayView.clearSelection()
|
|
backgroundOverlayView.clearSelection()
|
|
updateStaticGestureAvailability()
|
|
updateSelectionPanAvailability()
|
|
updateViewInteractionAvailability()
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
loadingSpinner.startAnimating()
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func clearSelection() {
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
currentSearchState = nil
|
|
interactionCoordinator.reset()
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
overlayView.clearSelection()
|
|
backgroundOverlayView.clearSelection()
|
|
updateSelectionPanAvailability()
|
|
updateViewInteractionAvailability()
|
|
updateAccessibilityDecorationSummary()
|
|
hideSelectionMenu()
|
|
}
|
|
|
|
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
|
let selection = currentSelection ?? menuSelection
|
|
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
|
menuSelection = nil
|
|
hideSelectionMenu()
|
|
}
|
|
|
|
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
|
guard page.pageIndexInChapter == 0,
|
|
page.href.lowercased().contains("cover"),
|
|
let image = coverImage(from: page.content) else {
|
|
return false
|
|
}
|
|
|
|
coverImageView.image = image
|
|
coverImageView.isHidden = false
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.isHidden = true
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
coreTextLayouter = nil
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
private func coverImage(from content: NSAttributedString) -> UIImage? {
|
|
guard content.length > 0 else { return nil }
|
|
var resolvedImage: UIImage?
|
|
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
|
|
guard let image = image(from: value) else { return }
|
|
resolvedImage = image
|
|
stop.pointee = true
|
|
}
|
|
return resolvedImage
|
|
}
|
|
|
|
private func image(from attachmentValue: Any?) -> UIImage? {
|
|
#if canImport(DTCoreText)
|
|
if let attachment = attachmentValue as? DTTextAttachment,
|
|
let url = attachment.contentURL {
|
|
return UIImage(contentsOfFile: url.path)
|
|
}
|
|
#endif
|
|
if let attachment = attachmentValue as? NSTextAttachment {
|
|
if let image = attachment.image { return image }
|
|
if let data = attachment.contents { return UIImage(data: data) }
|
|
if let fileWrapper = attachment.fileWrapper,
|
|
let data = fileWrapper.regularFileContents { return UIImage(data: data) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? {
|
|
guard page.chapterContent.length > attachment.stringRange.location else { return nil }
|
|
let attachmentValue = page.chapterContent.attribute(
|
|
.attachment,
|
|
at: attachment.stringRange.location,
|
|
effectiveRange: nil
|
|
)
|
|
return image(from: attachmentValue)
|
|
}
|
|
|
|
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
|
let content = NSMutableAttributedString(attributedString: page.content)
|
|
guard content.length > 0 else { return content }
|
|
let text = content.string as NSString
|
|
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
|
guard firstParagraphRange.length > 0 else { return content }
|
|
|
|
if shouldNormalizeLeadingParagraphSpacing(for: page) {
|
|
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
|
|
updateParagraphStyle(in: content, range: leadingRange) { style in
|
|
style.paragraphSpacingBefore = 0
|
|
}
|
|
}
|
|
|
|
guard shouldNormalizeContinuationParagraph(for: page),
|
|
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
|
return content
|
|
}
|
|
|
|
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
|
|
style.firstLineHeadIndent = style.headIndent
|
|
style.paragraphSpacingBefore = 0
|
|
}
|
|
return content
|
|
}
|
|
|
|
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
|
|
page.pageStartOffset == 0
|
|
}
|
|
|
|
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
|
|
let text = content.string as NSString
|
|
var index = 0
|
|
while index < text.length {
|
|
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
|
|
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
|
return text.paragraphRange(for: NSRange(location: index, length: 0))
|
|
}
|
|
index += 1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
|
let pageStart = page.pageStartOffset
|
|
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
|
|
let chapterText = page.chapterContent.string as NSString
|
|
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
|
|
return !CharacterSet.newlines.contains(previousScalar)
|
|
}
|
|
|
|
private func updateParagraphStyle(
|
|
in content: NSMutableAttributedString,
|
|
range: NSRange,
|
|
transform: (NSMutableParagraphStyle) -> Void
|
|
) {
|
|
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
|
|
guard let style = value as? NSParagraphStyle else { return }
|
|
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
|
transform(mutableStyle)
|
|
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
|
|
}
|
|
}
|
|
|
|
|
|
private func firstParagraphUsesNonLeadingAlignment(
|
|
in content: NSAttributedString,
|
|
range: NSRange
|
|
) -> Bool {
|
|
var usesNonLeadingAlignment = false
|
|
content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
|
|
guard let style = value as? NSParagraphStyle else { return }
|
|
if style.alignment == .right || style.alignment == .center {
|
|
usesNonLeadingAlignment = true
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
return usesNonLeadingAlignment
|
|
}
|
|
|
|
#if canImport(DTCoreText)
|
|
|
|
private func updateCoreTextLayoutFrameIfNeeded() {
|
|
guard !coreTextContentView.isHidden,
|
|
let displayContent = coreTextDisplayContent,
|
|
let displayRange = coreTextDisplayRange,
|
|
let page = currentPage,
|
|
coreTextContentView.bounds.width > 0,
|
|
coreTextContentView.bounds.height > 0 else {
|
|
interactionController.configure(layoutFrame: nil, page: currentPage)
|
|
return
|
|
}
|
|
|
|
guard let layouter = coreTextLayouter else {
|
|
coreTextContentView.layoutFrame = nil
|
|
interactionController.configure(layoutFrame: nil, page: page)
|
|
return
|
|
}
|
|
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
|
if let layoutFrame {
|
|
RDEPUBTextPageBoundaryValidator.validate(
|
|
page: page,
|
|
displayLayoutFrame: layoutFrame,
|
|
displayContent: displayContent,
|
|
displayBounds: coreTextContentView.bounds
|
|
)
|
|
RDEPUBTextPageVerticalJustifier.justify(
|
|
layoutFrame,
|
|
contentHeight: coreTextContentView.bounds.height,
|
|
isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
|
|
pixelScale: window?.screen.scale ?? UIScreen.main.scale
|
|
)
|
|
}
|
|
coreTextContentView.layoutFrame = layoutFrame
|
|
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
|
overlayView.updateSnapshot(interactionController.snapshot)
|
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
|
|
|
if let page = currentPage {
|
|
// Highlights/underlines and search are all drawn as overlay
|
|
// decorations; the shared chapter display string carries no
|
|
// page-level annotation attributes.
|
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
|
page: page,
|
|
highlights: currentHighlights,
|
|
searchState: currentSearchState,
|
|
interactionController: interactionController
|
|
)
|
|
backgroundOverlayView.applyDecorations(bgDecorations)
|
|
overlayView.applyDecorations(fgDecorations)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
|
interactionCoordinator.handleLongPress(gesture)
|
|
}
|
|
|
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
interactionCoordinator.handlePan(gesture)
|
|
}
|
|
|
|
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
|
|
RDReaderTapDebug.log(
|
|
"TextContentView.interactionAvailability",
|
|
"updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)"
|
|
)
|
|
}
|
|
|
|
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
|
|
RDReaderTapDebug.log(
|
|
"TextContentView.gestureAvailability",
|
|
"updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)"
|
|
)
|
|
}
|
|
|
|
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
|
let point = gesture.location(in: overlayView)
|
|
RDReaderTapDebug.log(
|
|
"TextContentView.handleTap",
|
|
"received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)"
|
|
)
|
|
if let renderView = coreTextRenderView {
|
|
let renderPoint = gesture.location(in: renderView)
|
|
if renderView.selectionHandle(at: renderPoint) != nil {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle")
|
|
return
|
|
}
|
|
if currentSelection != nil {
|
|
if renderView.selectionContains(renderPoint) {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu")
|
|
showSelectionMenuIfNeeded()
|
|
return
|
|
}
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection")
|
|
clearSelection()
|
|
return
|
|
}
|
|
} else if currentSelection != nil {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection")
|
|
clearSelection()
|
|
return
|
|
}
|
|
// PRIORITY: Check for attachment tap
|
|
if let page = currentPage,
|
|
let attachment = interactionController.snapshot?.attachment(at: point) {
|
|
let kind = attachment.kind
|
|
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero
|
|
// Footnote attachments carry their note text in alt/accessibility metadata.
|
|
// Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
|
|
if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap")
|
|
delegate?.textContentView(
|
|
self,
|
|
didActivateAttachmentText: footnoteText,
|
|
sourceRect: sourceRect,
|
|
sourcePoint: convert(point, from: overlayView)
|
|
)
|
|
return
|
|
}
|
|
// Regular image attachments: present image viewer
|
|
if let image = imageFromPage(attachment: attachment, page: page) {
|
|
let altText = attachmentText(at: point)
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)")
|
|
delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
|
|
return
|
|
}
|
|
}
|
|
if let attachmentText = attachmentText(at: point),
|
|
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap")
|
|
delegate?.textContentView(
|
|
self,
|
|
didActivateAttachmentText: attachmentText,
|
|
sourceRect: sourceRect,
|
|
sourcePoint: convert(point, from: overlayView)
|
|
)
|
|
return
|
|
}
|
|
guard let highlight = highlight(at: point),
|
|
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
|
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
|
return
|
|
}
|
|
RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
|
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
|
}
|
|
|
|
private func showSelectionMenuIfNeeded() {
|
|
guard currentSelection != nil,
|
|
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
|
!targetRect.isEmpty else {
|
|
return
|
|
}
|
|
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
|
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
|
becomeFirstResponder()
|
|
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
|
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
|
DispatchQueue.main.async { [weak self, weak interaction] in
|
|
guard let self, self.currentSelection != nil else { return }
|
|
interaction?.presentEditMenu(with: config)
|
|
}
|
|
} else {
|
|
becomeFirstResponder()
|
|
let menuController = UIMenuController.shared
|
|
menuController.menuItems = [
|
|
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
|
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
|
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
|
]
|
|
menuController.setTargetRect(resolvedTargetRect, in: self)
|
|
menuController.setMenuVisible(true, animated: true)
|
|
}
|
|
}
|
|
|
|
private func hideSelectionMenu() {
|
|
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
|
interaction.dismissMenu()
|
|
} else {
|
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
|
}
|
|
}
|
|
|
|
private func updateSelectionLoupe(for point: CGPoint) {
|
|
#if canImport(DTCoreText)
|
|
guard let renderView = coreTextRenderView else { return }
|
|
let localPoint = convert(point, from: renderView)
|
|
selectionLoupeView.present(
|
|
sourceView: renderView,
|
|
focusPoint: point,
|
|
hostBounds: bounds.inset(by: contentInsets),
|
|
targetPoint: localPoint
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private var coreTextRenderView: RDEPUBTextPageRenderView? {
|
|
#if canImport(DTCoreText)
|
|
return coreTextContentView
|
|
#else
|
|
return nil
|
|
#endif
|
|
}
|
|
|
|
private func resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
|
|
guard let renderView = coreTextRenderView else { return rect }
|
|
return convert(rect, from: renderView)
|
|
}
|
|
|
|
private func updateAccessibilityDecorationSummary() {
|
|
#if canImport(DTCoreText)
|
|
coreTextContentView.accessibilityValue = [
|
|
backgroundOverlayView.decorationSummary(),
|
|
overlayView.decorationSummary()
|
|
].joined(separator: " | ")
|
|
#endif
|
|
}
|
|
|
|
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
|
guard let page = currentPage else { return nil }
|
|
let matches = currentHighlights.filter { highlight in
|
|
guard highlight.location.href == page.href,
|
|
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
|
return false
|
|
}
|
|
let overlap = NSIntersectionRange(range, page.contentRange)
|
|
guard overlap.length > 0 else { return false }
|
|
return interactionController.selectionRects(for: overlap).contains {
|
|
$0.insetBy(dx: -4, dy: -4).contains(point)
|
|
}
|
|
}
|
|
return matches.sorted { lhs, rhs in
|
|
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
|
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
|
if lhs.hasNote != rhs.hasNote {
|
|
return lhs.hasNote && !rhs.hasNote
|
|
}
|
|
return lhsRange < rhsRange
|
|
}.first
|
|
}
|
|
|
|
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
|
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
|
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
|
|
return overlayView.convert(fallbackRect, to: self)
|
|
}
|
|
let rects = interactionController.selectionRects(for: range)
|
|
if let first = rects.first {
|
|
var unionRect = first
|
|
for rect in rects.dropFirst() {
|
|
unionRect = unionRect.union(rect)
|
|
}
|
|
return overlayView.convert(unionRect, to: self)
|
|
}
|
|
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
|
|
return overlayView.convert(fallbackRect, to: self)
|
|
}
|
|
|
|
private func attachmentText(at point: CGPoint) -> String? {
|
|
guard let page = currentPage else { return nil }
|
|
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
|
|
return nil
|
|
}
|
|
guard page.chapterContent.length > attachmentRange.location else {
|
|
return nil
|
|
}
|
|
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
|
|
|
|
#if canImport(DTCoreText)
|
|
if let textAttachment = attachment as? DTTextAttachment,
|
|
let altText = (textAttachment.attributes["alt"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!altText.isEmpty {
|
|
return altText
|
|
}
|
|
#endif
|
|
|
|
if let fileAttachment = attachment as? NSTextAttachment,
|
|
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!altText.isEmpty {
|
|
return altText
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
|
|
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
|
|
return overlayView.convert(attachmentRect, to: self)
|
|
}
|
|
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
|
|
return overlayView.convert(fallbackRect, to: self)
|
|
}
|
|
|
|
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
|
|
#if canImport(DTCoreText)
|
|
if interactionCoordinator.interactionState == .selectionPending {
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending")
|
|
return true
|
|
}
|
|
|
|
guard let renderView = coreTextRenderView else {
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil")
|
|
return false
|
|
}
|
|
let renderPoint = convert(point, to: renderView)
|
|
if renderView.selectionHandle(at: renderPoint) != nil {
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle")
|
|
return true
|
|
}
|
|
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection")
|
|
return true
|
|
}
|
|
switch interactionCoordinator.interactionState {
|
|
case .idle:
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle")
|
|
return false
|
|
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
|
RDReaderTapDebug.log(
|
|
"TextContentView.shouldSuppressReaderTap",
|
|
"return true because interactionState=\(interactionCoordinator.interactionState)"
|
|
)
|
|
return true
|
|
}
|
|
#else
|
|
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable")
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesBegan(touches, with: event)
|
|
interactionCoordinator.touchesBegan(touches, with: event)
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesEnded(touches, with: event)
|
|
interactionCoordinator.touchesEnded(touches, with: event)
|
|
}
|
|
|
|
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesCancelled(touches, with: event)
|
|
interactionCoordinator.touchesCancelled(touches, with: event)
|
|
}
|
|
|
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
if gestureRecognizer === tapGestureRecognizer {
|
|
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
|
|
return false
|
|
}
|
|
if selectionController.hasActiveSelection {
|
|
if let renderView = coreTextRenderView {
|
|
let point = tapGestureRecognizer.location(in: renderView)
|
|
if renderView.selectionHandle(at: point) != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
let point = tapGestureRecognizer.location(in: overlayView)
|
|
if attachmentText(at: point) != nil || highlight(at: point) != nil {
|
|
return true
|
|
}
|
|
return currentPage != nil
|
|
}
|
|
return interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
|
|
}
|
|
|
|
func gestureRecognizer(
|
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
shouldReceive touch: UITouch
|
|
) -> Bool {
|
|
if gestureRecognizer === tapGestureRecognizer {
|
|
guard let renderView = coreTextRenderView else {
|
|
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil")
|
|
return true
|
|
}
|
|
|
|
let point = touch.location(in: renderView)
|
|
if renderView.selectionHandle(at: point) != nil {
|
|
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle")
|
|
return false
|
|
}
|
|
}
|
|
|
|
let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
|
|
RDReaderTapDebug.log(
|
|
"TextContentView.gestureShouldReceive",
|
|
"gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)"
|
|
)
|
|
return result
|
|
}
|
|
|
|
func gestureRecognizer(
|
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
|
) -> Bool {
|
|
gestureRecognizer === tapGestureRecognizer
|
|
|| interactionCoordinator.gestureRecognizer(
|
|
gestureRecognizer,
|
|
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
|
|
|
@available(iOS 16.0, *)
|
|
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
|
func editMenuInteraction(
|
|
_ interaction: UIEditMenuInteraction,
|
|
menuFor configuration: UIEditMenuConfiguration,
|
|
suggestedActions: [UIMenuElement]
|
|
) -> UIMenu? {
|
|
UIMenu(children: [
|
|
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
|
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
|
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
|
])
|
|
}
|
|
|
|
func editMenuInteraction(
|
|
_ interaction: UIEditMenuInteraction,
|
|
targetRectFor configuration: UIEditMenuConfiguration
|
|
) -> CGRect {
|
|
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
|
!targetRect.isEmpty {
|
|
return resolvedSelectionMenuTargetRect(from: targetRect)
|
|
}
|
|
return bounds
|
|
}
|
|
}
|
|
|
|
// MARK: - Safe Content Insets
|
|
|
|
extension RDEPUBTextContentView {
|
|
|
|
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
|
|
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
|
|
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
|
|
private static func safeContentInsets(
|
|
configuration: RDEPUBReaderConfiguration,
|
|
safeAreaInsets: UIEdgeInsets
|
|
) -> UIEdgeInsets {
|
|
let configInsets = configuration.reflowableContentInsets
|
|
return UIEdgeInsets(
|
|
top: max(configInsets.top, safeAreaInsets.top),
|
|
left: max(configInsets.left, safeAreaInsets.left),
|
|
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
|
right: max(configInsets.right, safeAreaInsets.right)
|
|
)
|
|
}
|
|
}
|