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 } }