refactor: split reader architecture and chrome handling

This commit is contained in:
shen
2026-05-31 21:47:54 +08:00
parent ea21c6a831
commit 44202357c0
80 changed files with 10635 additions and 8522 deletions
@@ -0,0 +1,217 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText
///
final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot?
private var dtLayoutFrame: DTCoreTextLayoutFrame?
#if canImport(DTCoreText)
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
dtLayoutFrame = layoutFrame
if let layoutFrame, let page {
snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page)
} else {
snapshot = nil
}
}
#endif
// MARK: - Hit Testing
func characterIndex(at point: CGPoint) -> Int? {
guard let snapshot else { return nil }
// Attachment rect priority (6pt inset for easier tapping)
for attachment in snapshot.attachments {
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
return attachment.stringRange.location
}
}
guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil }
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let relativePoint = CGPoint(
x: point.x - line.baselineOrigin.x,
y: point.y - line.baselineOrigin.y
)
let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange)
#else
return nil
#endif
}
// MARK: - Selection Range
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
guard let start = characterIndex(at: startPoint),
let end = characterIndex(at: endPoint) else { return nil }
let lower = min(start, end)
let upper = max(start, end)
return NSRange(location: lower, length: max(upper - lower, 1))
}
// MARK: - Selection Rects
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
guard let snapshot else { return [] }
var rects: [CGRect] = []
for line in snapshot.lines {
let overlap = NSIntersectionRange(line.stringRange, absoluteRange)
guard overlap.length > 0 else { continue }
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
let startX = dtLine.offset(forStringIndex: overlap.location)
let endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx)
#else
let startX: CGFloat = 0
let endX: CGFloat = line.frame.width
#endif
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: max(endX - startX, 2),
height: line.ascent + line.descent
)
rects.append(rect)
}
return mergeAdjacentRects(rects)
}
func firstRect(for absoluteRange: NSRange) -> CGRect? {
selectionRects(for: absoluteRange).first
}
func lastRect(for absoluteRange: NSRange) -> CGRect? {
selectionRects(for: absoluteRange).last
}
func boundingRect(for absoluteRange: NSRange) -> CGRect? {
let rects = selectionRects(for: absoluteRange)
guard var rect = rects.first else { return nil }
for next in rects.dropFirst() {
rect = rect.union(next)
}
return rect
}
func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? {
guard let first = firstRect(for: absoluteRange),
let last = lastRect(for: absoluteRange) else {
return boundingRect(for: absoluteRange)
}
let minX = min(first.minX, last.minX)
let maxX = max(first.maxX, last.maxX)
let minY = min(first.minY, last.minY)
let maxY = max(first.maxY, last.maxY)
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
}
// MARK: - Caret Rect
func caretRect(at index: Int) -> CGRect? {
guard let snapshot else { return nil }
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
return nil
}
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let offsetX = dtLine.offset(forStringIndex: index)
#else
let offsetX: CGFloat = 0
#endif
return CGRect(
x: line.baselineOrigin.x + offsetX - 1,
y: line.baselineOrigin.y - line.ascent,
width: 2,
height: line.ascent + line.descent
)
}
// MARK: - Private Helpers
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
var bestLine: RDEPUBPageLine?
var bestDistance: CGFloat = .greatestFiniteMagnitude
for line in lines {
let lineBottom = line.baselineOrigin.y + line.descent
let lineTop = line.baselineOrigin.y - line.ascent
if point.y >= lineTop && point.y <= lineBottom {
return line
}
let lineMidY = (lineTop + lineBottom) / 2
let distance = abs(point.y - lineMidY)
if distance < bestDistance {
bestDistance = distance
bestLine = line
}
}
return bestLine
}
private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int {
var result = idx
if result < lineRange.location {
result = lineRange.location
}
let lineEnd = lineRange.location + lineRange.length
if result >= lineEnd {
result = max(lineEnd - 1, lineRange.location)
}
return result
}
#if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(range.location))
}
#endif
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects }
let sorted = rects.sorted { a, b in
if abs(a.origin.y - b.origin.y) < 1 {
return a.origin.x < b.origin.x
}
return a.origin.y < b.origin.y
}
var merged: [CGRect] = [sorted[0]]
for rect in sorted.dropFirst() {
let last = merged[merged.count - 1]
if abs(rect.origin.y - last.origin.y) < 1,
rect.origin.x <= last.maxX + 2 {
merged[merged.count - 1] = last.union(rect)
} else {
merged.append(rect)
}
}
return merged
}
}
@@ -0,0 +1,222 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText 线
struct RDEPUBPageLine {
/// chapterContent
let stringRange: NSRange
///
let frame: CGRect
/// 线
let baselineOrigin: CGPoint
/// 线
let ascent: CGFloat
/// 线
let descent: CGFloat
///
let leading: CGFloat
}
/// Run
///
struct RDEPUBPageRun {
/// Run
let stringRange: NSRange
/// Run
let frame: CGRect
///
let isAttachment: Bool
}
///
///
struct RDEPUBPageAttachment {
///
let stringRange: NSRange
///
let frame: CGRect
///
let displaySize: CGSize
/// /
let placement: RDEPUBTextAttachmentPlacement?
/// /
let kind: RDEPUBTextAttachmentKind?
}
// MARK: -
///
/// DTCoreText
///
struct RDEPUBPageLayoutSnapshot {
let page: RDEPUBTextPage
let lines: [RDEPUBPageLine]
let runs: [RDEPUBPageRun]
let attachments: [RDEPUBPageAttachment]
let pageContentRange: NSRange
#if canImport(DTCoreText)
let layoutFrame: DTCoreTextLayoutFrame
#endif
var contentBounds: CGRect {
let rects = lines.map(\.frame) + attachments.map(\.frame)
guard var bounds = rects.first else { return .zero }
for rect in rects.dropFirst() {
bounds = bounds.union(rect)
}
return bounds
}
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
}
func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
}
func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
}
func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
lines.first { line in
let lineRect = CGRect(
x: line.frame.minX,
y: line.baselineOrigin.y - line.ascent,
width: max(line.frame.width, 1),
height: line.ascent + line.descent + line.leading
)
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
}
}
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
}
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
decorations.filter { decoration in
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
}
}
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
if let attachment = attachment(at: point) {
return attachment.stringRange
}
if let run = run(at: point) {
return run.stringRange
}
let hitDecorations = rects(containing: point, in: decorations)
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
lhs.absoluteRange.length < rhs.absoluteRange.length
}) {
return mostSpecific.absoluteRange
}
return line(at: point)?.stringRange
}
#if canImport(DTCoreText)
static func build(
from layoutFrame: DTCoreTextLayoutFrame,
page: RDEPUBTextPage
) -> RDEPUBPageLayoutSnapshot? {
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
return nil
}
var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = []
let pageOffset = page.pageStartOffset
for dtLine in dtLines {
let lineRange = offset(dtLine.stringRange(), by: pageOffset)
let line = RDEPUBPageLine(
stringRange: lineRange,
frame: dtLine.frame,
baselineOrigin: dtLine.baselineOrigin,
ascent: dtLine.ascent,
descent: dtLine.descent,
leading: dtLine.leading
)
lines.append(line)
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
for run in glyphRuns {
let runRange = offset(run.stringRange(), by: pageOffset)
let isAttachment = run.attachment != nil
runs.append(
RDEPUBPageRun(
stringRange: runRange,
frame: run.frame,
isAttachment: isAttachment
)
)
guard isAttachment else { continue }
let metadata = attachmentMetadata(
for: runRange,
on: page
)
attachments.append(
RDEPUBPageAttachment(
stringRange: runRange,
frame: run.frame,
displaySize: run.frame.size,
placement: metadata.placement,
kind: metadata.kind
)
)
}
}
}
let visibleRange = offset(layoutFrame.visibleStringRange(), by: pageOffset)
return RDEPUBPageLayoutSnapshot(
page: page,
lines: lines,
runs: runs,
attachments: attachments,
pageContentRange: visibleRange,
layoutFrame: layoutFrame
)
}
private static func attachmentMetadata(
for range: NSRange,
on page: RDEPUBTextPage
) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
return (nil, nil)
}
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
? page.metadata.attachmentPlacements[attachmentIndex]
: nil
let kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
? page.metadata.attachmentKinds[attachmentIndex]
: nil
return (placement, kind)
}
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {
guard range.location != NSNotFound else {
return range
}
return NSRange(location: range.location + offset, length: range.length)
}
#endif
}
@@ -0,0 +1,30 @@
import UIKit
/// 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)
}
}
@@ -0,0 +1,181 @@
import UIKit
// MARK: -
///
///
struct RDEPUBTextOverlayDecoration {
///
enum Kind: String {
///
case selection
///
case highlight
/// 线
case underline
///
case search
///
case activeSearch
///
case locate
}
///
var kind: Kind
///
var absoluteRange: NSRange
///
var rects: [CGRect]
///
var color: UIColor
}
// MARK: -
///
///
/// 使 Core Graphics 线
class RDEPUBSelectionOverlayView: UIView {
private(set) var page: RDEPUBTextPage?
private var snapshot: RDEPUBPageLayoutSnapshot?
private(set) var selectionRange: NSRange?
private var selectionRects: [CGRect] = []
private var decorations: [RDEPUBTextOverlayDecoration] = []
private let selectionVerticalAdjustment: CGFloat = -1
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
self.page = page
self.snapshot = snapshot
self.selectionColor = selectionColor
selectionRange = nil
decorations = []
setNeedsDisplay()
}
func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
self.snapshot = snapshot
}
func updateSelection(absoluteRange: NSRange?) {
selectionRange = absoluteRange
selectionRects = []
setNeedsDisplay()
}
func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
selectionRange = absoluteRange
selectionRects = rects
setNeedsDisplay()
}
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
self.decorations = decorations
setNeedsDisplay()
}
func clearSelection() {
updateSelection(absoluteRange: nil)
}
func absoluteRange(at point: CGPoint) -> NSRange? {
if let selectionRange,
selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
return selectionRange
}
if let snapshot,
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
return absoluteRange
}
for decoration in resolvedDecorations {
for rect in decoration.rects {
if rect.insetBy(dx: -4, dy: -4).contains(point) {
return decoration.absoluteRange
}
}
}
return nil
}
func decorationSummary() -> String {
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
let highlightCount = counts[.highlight, default: 0]
let underlineCount = counts[.underline, default: 0]
let searchCount = counts[.search, default: 0]
let activeSearchCount = counts[.activeSearch, default: 0]
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
return [
"selection \(selectionLabel)",
"highlight \(highlightCount)",
"underline \(underlineCount)",
"search \(searchCount)",
"activeSearch \(activeSearchCount)",
"locate \(locateLabel)"
].joined(separator: " · ")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for decoration in resolvedDecorations {
switch decoration.kind {
case .underline:
context.setStrokeColor(decoration.color.cgColor)
context.setLineWidth(2)
for underlineRect in decoration.rects {
let y = underlineRect.maxY - 1
context.move(to: CGPoint(x: underlineRect.minX, y: y))
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
context.strokePath()
}
default:
context.setFillColor(decoration.color.cgColor)
for selectionRect in decoration.rects {
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
guard page != nil else { return [] }
var result = decorations.filter { !$0.rects.isEmpty }
if let selectionRange, !selectionRects.isEmpty {
result.append(
RDEPUBTextOverlayDecoration(
kind: .selection,
absoluteRange: selectionRange,
rects: selectionRects,
color: selectionColor
)
)
}
return result
}
}
@@ -0,0 +1,136 @@
import UIKit
///
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
func applyHighlights(
_ highlights: [RDEPUBHighlight],
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for highlight in highlights 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)
}
}
}
}
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)
}
}
func buildDecorations(
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight],
searchState: RDEPUBSearchState?,
interactionController: RDEPUBPageInteractionController
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
var background: [RDEPUBTextOverlayDecoration] = []
var foreground: [RDEPUBTextOverlayDecoration] = []
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
if let searchState {
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))
}
}
for highlight in highlights 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)
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
}
@@ -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
}
@@ -0,0 +1,4 @@
import UIKit
///
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
@@ -0,0 +1,41 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
final class RDEPUBTextPageRenderView: 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
@@ -0,0 +1,189 @@
import UIKit
///
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
private var isSelectionFromInteraction = false
private var selectionAnchorPoint: CGPoint?
private var selectionMenuAnchorRect: CGRect?
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool {
overlayView.selectionRange?.length ?? 0 > 0
}
func clearSelection(
textView: UITextView,
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
textView.selectedRange = NSRange(location: 0, length: 0)
overlayView.clearSelection()
backgroundOverlayView?.clearSelection()
selectionAnchorPoint = nil
selectionMenuAnchorRect = nil
isSelectionFromInteraction = false
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
func handleLongPress(
_ gesture: UILongPressGestureRecognizer,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
let point = gesture.location(in: overlayView)
switch gesture.state {
case .began:
selectionAnchorPoint = point
isSelectionFromInteraction = true
handleSelectionFromInteraction(
point: point,
anchorPoint: nil,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .changed:
guard let anchor = selectionAnchorPoint else { return }
handleSelectionFromInteraction(
point: point,
anchorPoint: anchor,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .ended:
isSelectionFromInteraction = false
default:
break
}
}
func handleTap(
textView: UITextView,
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
func showSelectionMenuIfNeeded(
in hostView: UIView,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController,
copyAction: Selector,
highlightAction: Selector,
annotateAction: Selector
) {
guard overlayView.selectionRange?.length ?? 0 > 0,
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
return
}
hostView.becomeFirstResponder()
let menuRect = overlayView.convert(anchorRect, to: hostView)
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: copyAction),
UIMenuItem(title: "高亮", action: highlightAction),
UIMenuItem(title: "批注", action: annotateAction)
]
menuController.setTargetRect(menuRect, in: hostView)
menuController.setMenuVisible(true, animated: true)
}
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
guard !isSelectionFromInteraction else { return }
guard let page else {
onSelectionChanged?(nil)
return
}
let selectedRange = textView.selectedRange
guard selectedRange.location != NSNotFound,
selectedRange.length > 0,
let attributedText = textView.attributedText else {
onSelectionChanged?(nil)
return
}
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
onSelectionChanged?(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()
)
onSelectionChanged?(selection)
}
private func handleSelectionFromInteraction(
point: CGPoint,
anchorPoint: CGPoint?,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
guard let page else { return }
let range: NSRange?
if let anchorPoint {
range = interactionController.selectionRange(from: anchorPoint, 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)
onSelectionChanged?(makeSelection(from: range, page: page))
}
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
let source = page.chapterContent.string as NSString
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
return nil
}
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
return 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()
)
}
}