182 lines
6.1 KiB
Swift
182 lines
6.1 KiB
Swift
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 直接绘制,支持填充矩形和下划线两种绘制模式
|
|
final 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
|
|
}
|
|
}
|