refactor: split reader architecture and chrome handling
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user