refactor: split reader architecture and chrome handling
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
||||
let view = RDEPUBTextPageRenderView()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
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 textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
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
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(backgroundOverlayView)
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = selectionController
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.4
|
||||
addGestureRecognizer(longPress)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectionController.canPerformSelectionAction(in: overlayView)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
#else
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
#endif
|
||||
}
|
||||
|
||||
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)
|
||||
textView.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
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
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
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
}
|
||||
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
let selectionContent = normalizedPageContent(from: page)
|
||||
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
||||
selectionContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: selectionRange
|
||||
)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = normalizedPageContent(from: page)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
textView.attributedText = nil
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
#endif
|
||||
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
highlights: highlights,
|
||||
searchState: searchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
selectionController.clearSelection(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
selectionController.handleLongPress(
|
||||
gesture,
|
||||
page: currentPage,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
if gesture.state == .ended {
|
||||
showSelectionMenuIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
selectionController.handleTap(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy)
|
||||
}
|
||||
|
||||
@objc private func rd_highlight(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight)
|
||||
}
|
||||
|
||||
@objc private func rd_annotate(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
#if canImport(DTCoreText)
|
||||
selectionController.showSelectionMenuIfNeeded(
|
||||
in: self,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController,
|
||||
copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)),
|
||||
highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)),
|
||||
annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:))
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
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
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
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 selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
proxy.removeAttribute(.backgroundColor, range: fullRange)
|
||||
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
|
||||
|
||||
var attachmentRanges: [NSRange] = []
|
||||
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||
guard value != nil else { return }
|
||||
attachmentRanges.append(range)
|
||||
}
|
||||
|
||||
for range in attachmentRanges.reversed() {
|
||||
let replacement = NSAttributedString(
|
||||
string: String(repeating: " ", count: max(range.length, 1)),
|
||||
attributes: [
|
||||
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
|
||||
.foregroundColor: UIColor.clear
|
||||
]
|
||||
)
|
||||
proxy.replaceCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user