ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift

716 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: -
/// UITextView UIMenuItem
final class RDEPUBSelectableTextView: UITextView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectedRange.location != NSNotFound && selectedRange.length > 0
default:
return false
}
}
@objc func rd_copy(_ sender: Any?) {
onSelectionAction?(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
onSelectionAction?(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
onSelectionAction?(.annotate)
}
}
// MARK: - DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
#if canImport(DTCoreText)
final class RDEPUBDirectCoreTextPageView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
layoutFrame.draw(in: context, options: drawOptions)
context.restoreGState()
}
}
#endif
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var highlightedRanges: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
private var isSelectionFromInteraction = false
private var selectionMenuAnchorRect: CGRect?
weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText)
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
let view = RDEPUBDirectCoreTextPageView()
view.backgroundColor = .clear
view.isOpaque = false
return view
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
#endif
private let interactionController = RDEPUBPageInteractionController()
private let backgroundOverlayView: RDEPUBSelectionOverlayView = {
let view = RDEPUBSelectionOverlayView()
return view
}()
private let overlayView: RDEPUBSelectionOverlayView = {
let view = RDEPUBSelectionOverlayView()
return view
}()
private var selectionAnchorPoint: CGPoint?
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 = self
textView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.delegate?.textContentView(self, didRequestSelectionAction: action)
}
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 overlayView.selectionRange?.length ?? 0 > 0
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
highlightedRanges = highlights
currentSearchState = searchState
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 = NSMutableAttributedString(attributedString: page.content)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
#if canImport(DTCoreText)
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
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 = page.contentRange
textView.isHidden = true
textView.isUserInteractionEnabled = false
textView.attributedText = nil
updateCoreTextLayoutFrameIfNeeded()
#else
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
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) = buildOverlayDecorations(page: page)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func clearSelection() {
textView.selectedRange = NSRange(location: 0, length: 0)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
selectionAnchorPoint = nil
selectionMenuAnchorRect = nil
isSelectionFromInteraction = false
UIMenuController.shared.setMenuVisible(false, animated: true)
delegate?.textContentView(self, didChangeSelection: nil)
}
// MARK: - Gesture Handling
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
let point = gesture.location(in: overlayView)
switch gesture.state {
case .began:
selectionAnchorPoint = point
isSelectionFromInteraction = true
handleSelectionFromInteraction(point: point, anchorPoint: nil)
case .changed:
guard let anchor = selectionAnchorPoint else { return }
handleSelectionFromInteraction(point: point, anchorPoint: anchor)
case .ended:
isSelectionFromInteraction = false
showSelectionMenuIfNeeded()
default:
break
}
}
private func handleSelectionFromInteraction(point: CGPoint, anchorPoint: CGPoint?) {
guard let page = currentPage else { return }
let range: NSRange?
if let anchor = anchorPoint {
range = interactionController.selectionRange(from: anchor, to: point)
} else if let idx = interactionController.characterIndex(at: point) {
range = NSRange(location: idx, length: 1)
} else {
range = nil
}
guard let range else { return }
let rects = interactionController.selectionRects(for: range)
overlayView.updateSelection(absoluteRange: range, rects: rects)
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
notifySelectionChange(range: range, page: page)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
clearSelection()
}
@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 notifySelectionChange(range: NSRange, page: RDEPUBTextPage) {
let source = page.chapterContent.string as NSString
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
)
delegate?.textContentView(self, didChangeSelection: selection)
}
private func showSelectionMenuIfNeeded() {
#if canImport(DTCoreText)
guard overlayView.selectionRange?.length ?? 0 > 0,
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
return
}
becomeFirstResponder()
let menuRect = overlayView.convert(anchorRect, to: self)
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
]
menuController.setTargetRect(menuRect, in: self)
menuController.setMenuVisible(true, animated: true)
#endif
}
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
}
private func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for highlight in highlightedRanges where highlight.location.href == page.href {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
let overlapStart = max(range.location, pageStart)
let overlapEnd = min(range.location + range.length, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(
location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
case .highlight:
content.addAttribute(
.backgroundColor,
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
range: relativeRange
)
case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange)
}
}
}
}
private func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?
) {
applySearchHighlights(
to: content,
page: page,
searchState: searchState,
contentBaseOffset: page.pageStartOffset
)
}
private func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?,
contentBaseOffset: Int
) {
guard let searchState else { return }
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for match in searchState.matches where match.href == page.href {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
#if canImport(DTCoreText)
private func buildOverlayDecorations(page: RDEPUBTextPage) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
var background: [RDEPUBTextOverlayDecoration] = []
var foreground: [RDEPUBTextOverlayDecoration] = []
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
// Search results background (behind text)
if let searchState = currentSearchState {
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
for match in searchState.matches where match.href == page.href {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
let overlapEnd = min(matchEnd, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue }
let isActive = match == searchState.currentMatch
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
let color = isActive ? activeColor : normalColor
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
}
}
// Highlights background (filled) or foreground (underline)
for highlight in highlightedRanges where highlight.location.href == page.href {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
let overlapStart = max(range.location, pageStart)
let overlapEnd = min(range.location + range.length, pageEndExclusive)
guard overlapStart < overlapEnd else { continue }
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue }
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
let decoration = RDEPUBTextOverlayDecoration(
kind: highlight.style == .underline ? .underline : .highlight,
absoluteRange: absoluteRange,
rects: rects,
color: color
)
if decoration.kind == .underline {
foreground.append(decoration)
} else {
background.append(decoration)
}
}
return (background, foreground)
}
#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
}
#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
}
extension RDEPUBTextContentView: UITextViewDelegate {
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isSelectionFromInteraction else { return }
guard let page = currentPage else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let selectedRange = textView.selectedRange
guard selectedRange.location != NSNotFound,
selectedRange.length > 0,
let attributedText = textView.attributedText else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
)
delegate?.textContentView(self, didChangeSelection: selection)
}
}