Phase 1: Context 拆分 - 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices - RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services Phase 2: Runtime 拆分 - 新增 RDEPUBPresentationRuntime 处理分页状态管理 - 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排 - RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade Phase 0.5: 性能优化 - prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false) - extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载 - RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争 - CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded) - RDEPUBTextPageRenderView 引入静态位图缓存 - RDEPUBTextContentView 新增 loadingSpinner 占位页 Phase 3: 状态机 - 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验) - 新增 RDEPUBPaginationState 记录分页来源 Review 修复 - makeSummary 重复方法合并 - ensureNavigationTargetAvailable 同步路径加注释标记 UI 测试 - 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景) - 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
1085 lines
42 KiB
Swift
1085 lines
42 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,
|
|
didRequestHighlightActions highlight: RDEPUBHighlight,
|
|
sourceRect: CGRect
|
|
)
|
|
}
|
|
|
|
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>()
|
|
|
|
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?
|
|
|
|
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
|
|
|
|
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
|
|
|
private var selectionInteractionState: SelectionInteractionState = .idle
|
|
|
|
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
|
|
}()
|
|
|
|
private var coreTextDisplayContent: NSAttributedString?
|
|
|
|
private var coreTextDisplayRange: NSRange?
|
|
#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
|
|
}()
|
|
|
|
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.delegate = self
|
|
return gesture
|
|
}()
|
|
|
|
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
|
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
|
gesture.delegate = self
|
|
return gesture
|
|
}()
|
|
|
|
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)
|
|
addGestureRecognizer(longPressGestureRecognizer)
|
|
addGestureRecognizer(panGestureRecognizer)
|
|
addGestureRecognizer(tapGestureRecognizer)
|
|
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
|
selectionLoupeView.isHidden = true
|
|
|
|
selectionController.onSelectionChanged = { [weak self] selection in
|
|
guard let self else { return }
|
|
self.currentSelection = selection
|
|
if let selection {
|
|
self.menuSelection = selection
|
|
} else {
|
|
self.hideSelectionMenu()
|
|
}
|
|
self.updateAccessibilityDecorationSummary()
|
|
self.delegate?.textContentView(self, didChangeSelection: selection)
|
|
}
|
|
selectionController.interactionStateDidChange = { [weak self] state in
|
|
self?.handleSelectionControllerStateChange(state)
|
|
}
|
|
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 {
|
|
selectionInteractionState != .idle
|
|
}
|
|
|
|
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 labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
|
pageNumberLabel.frame = CGRect(
|
|
x: bounds.width - labelSize.width - 24,
|
|
y: bounds.height - labelSize.height - 20,
|
|
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
|
|
) {
|
|
loadingSpinner.stopAnimating()
|
|
currentPage = page
|
|
currentChapterCFIMap = chapterCFIMap
|
|
currentChapterFragmentOffsets = chapterFragmentOffsets
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
updateSelectionInteractionState(.idle)
|
|
currentHighlights = highlights
|
|
currentSearchState = searchState
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
contentInsets = configuration.reflowableContentInsets
|
|
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
|
|
#endif
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
setNeedsLayout()
|
|
return
|
|
}
|
|
|
|
coverImageView.isHidden = true
|
|
coverImageView.image = nil
|
|
|
|
#if canImport(DTCoreText)
|
|
let displayContent = darkImageAdjustedContentIfNeeded(
|
|
normalizedPageContent(from: page),
|
|
configuration: configuration
|
|
)
|
|
let fullRange = NSRange(location: 0, length: displayContent.length)
|
|
displayContent.addAttribute(
|
|
.foregroundColor,
|
|
value: configuration.theme.contentTextColor,
|
|
range: fullRange
|
|
)
|
|
|
|
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
|
coreTextContentView.isHidden = false
|
|
coreTextContentView.backgroundColor = .clear
|
|
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
|
coreTextDisplayContent = displayContent
|
|
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
|
coreTextContentView.attributedDisplayContent = displayContent
|
|
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
|
|
updateAccessibilityDecorationSummary()
|
|
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func configureLoading(
|
|
pageNumber: Int,
|
|
totalPages: Int,
|
|
configuration: RDEPUBReaderConfiguration
|
|
) {
|
|
currentPage = nil
|
|
currentChapterCFIMap = nil
|
|
currentChapterFragmentOffsets = [:]
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
activeSelectionHandle = nil
|
|
updateSelectionInteractionState(.idle)
|
|
currentHighlights = []
|
|
currentSearchState = nil
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
contentInsets = configuration.reflowableContentInsets
|
|
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.attributedDisplayContent = nil
|
|
coreTextContentView.layoutFrame = nil
|
|
coreTextDisplayContent = nil
|
|
coreTextDisplayRange = nil
|
|
#endif
|
|
|
|
overlayView.clearSelection()
|
|
backgroundOverlayView.clearSelection()
|
|
delegate?.textContentView(self, didChangeSelection: nil)
|
|
loadingSpinner.startAnimating()
|
|
setNeedsLayout()
|
|
}
|
|
|
|
func clearSelection() {
|
|
currentSelection = nil
|
|
menuSelection = nil
|
|
currentSearchState = nil
|
|
activeSelectionHandle = nil
|
|
updateSelectionInteractionState(.idle)
|
|
selectionLoupeView.dismiss()
|
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
|
overlayView.clearSelection()
|
|
backgroundOverlayView.clearSelection()
|
|
updateAccessibilityDecorationSummary()
|
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
|
}
|
|
|
|
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
|
|
#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
|
|
}
|
|
|
|
#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],
|
|
page: RDEPUBTextPage
|
|
) {
|
|
for highlight in highlights {
|
|
guard let rangeInfo = highlight.rangeInfo,
|
|
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
|
|
let absoluteRange = info.nsRange else { continue }
|
|
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
|
|
guard overlap.length > 0 else { continue }
|
|
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
|
guard relativeRange.location >= 0,
|
|
relativeRange.location + relativeRange.length <= content.length else { continue }
|
|
|
|
switch highlight.style {
|
|
case .highlight:
|
|
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
|
case .underline:
|
|
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
|
let content = NSMutableAttributedString(attributedString: page.content)
|
|
guard shouldNormalizeContinuationParagraph(for: page) 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 }
|
|
|
|
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
|
guard let style = value as? NSParagraphStyle else { return }
|
|
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
|
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
|
mutableStyle.paragraphSpacingBefore = 0
|
|
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
|
}
|
|
return content
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
#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 = DTCoreTextLayouter(attributedString: displayContent) else {
|
|
coreTextContentView.layoutFrame = nil
|
|
interactionController.configure(layoutFrame: nil, page: page)
|
|
return
|
|
}
|
|
layouter.shouldCacheLayoutFrames = false
|
|
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
|
coreTextContentView.layoutFrame = layoutFrame
|
|
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
|
overlayView.updateSnapshot(interactionController.snapshot)
|
|
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
|
|
|
if let page = currentPage {
|
|
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
|
page: page,
|
|
highlights: [],
|
|
searchState: currentSearchState,
|
|
interactionController: interactionController
|
|
)
|
|
backgroundOverlayView.applyDecorations(bgDecorations)
|
|
overlayView.applyDecorations(fgDecorations)
|
|
}
|
|
}
|
|
#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
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
|
let point = gesture.location(in: overlayView)
|
|
if let renderView = coreTextRenderView {
|
|
let renderPoint = gesture.location(in: renderView)
|
|
if renderView.selectionHandle(at: renderPoint) != nil {
|
|
return
|
|
}
|
|
if currentSelection != nil {
|
|
if renderView.selectionContains(renderPoint) {
|
|
showSelectionMenuIfNeeded()
|
|
return
|
|
}
|
|
clearSelection()
|
|
return
|
|
}
|
|
} else if currentSelection != nil {
|
|
clearSelection()
|
|
return
|
|
}
|
|
if let attachmentText = attachmentText(at: point),
|
|
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
|
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 {
|
|
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
|
return
|
|
}
|
|
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
|
}
|
|
|
|
private func showSelectionMenuIfNeeded() {
|
|
guard currentSelection != nil,
|
|
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
|
!targetRect.isEmpty else {
|
|
return
|
|
}
|
|
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(targetRect, in: coreTextRenderView ?? self)
|
|
menuController.setMenuVisible(true, animated: true)
|
|
}
|
|
|
|
private func hideSelectionMenu() {
|
|
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 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
|
|
#else
|
|
return nil
|
|
#endif
|
|
}
|
|
|
|
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 absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
|
|
guard let absoluteRange else { return nil }
|
|
let matches = currentHighlights.filter { highlight in
|
|
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
|
return false
|
|
}
|
|
return NSIntersectionRange(range, absoluteRange).length > 0
|
|
}
|
|
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 selectionInteractionState == .selectionPending {
|
|
return true
|
|
}
|
|
|
|
guard let renderView = coreTextRenderView else {
|
|
return false
|
|
}
|
|
let renderPoint = convert(point, to: renderView)
|
|
if renderView.selectionHandle(at: renderPoint) != nil {
|
|
return true
|
|
}
|
|
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
|
return true
|
|
}
|
|
switch selectionInteractionState {
|
|
case .idle:
|
|
return false
|
|
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
|
return true
|
|
}
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesEnded(touches, with: event)
|
|
resetSelectionPendingIfNeeded()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
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 true
|
|
}
|
|
|
|
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()
|
|
return true
|
|
}
|
|
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
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
|
|
)
|
|
)
|
|
|
|
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
|
|
}
|
|
}
|