import UIKit 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 } 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 attachment = snapshot.attachment(at: point) { return attachment.stringRange } if let snapshot { let hitDecorations = snapshot.rects(containing: point, in: resolvedDecorations) if let mostSpecific = hitDecorations.min(by: { lhs, rhs in lhs.absoluteRange.length < rhs.absoluteRange.length }) { return mostSpecific.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 } }