import UIKit #if canImport(DTCoreText) import DTCoreText final class RDEPUBTextPageRenderView: UIView { enum SelectionHandle { case start case end } var layoutFrame: DTCoreTextLayoutFrame? { didSet { invalidateStaticContent() } } var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! { didSet { invalidateStaticContent() } } var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } } var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1) private let selectionHandleStemWidth: CGFloat = 2.5 private let selectionHandleKnobRadius: CGFloat = 7 private let selectionHandleHitSlop: CGFloat = 20 private var cachedStaticImage: UIImage? private var cachedStaticBoundsSize: CGSize = .zero private var needsStaticContentRedraw = true 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() if cachedStaticImage == nil || cachedStaticBoundsSize != bounds.size || needsStaticContentRedraw { cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame) cachedStaticBoundsSize = bounds.size needsStaticContentRedraw = false } if let cachedStaticImage { cachedStaticImage.draw(in: bounds) } else { layoutFrame.draw(in: context, options: drawOptions) } drawSelection(in: context) context.restoreGState() } override func layoutSubviews() { super.layoutSubviews() if cachedStaticBoundsSize != bounds.size { invalidateStaticContent() } } private func drawSelection(in context: CGContext) { guard !selectionRects.isEmpty else { return } selectionColor.setFill() for rect in selectionRects { context.fill(rect) } drawSelectionHandles(in: context) } func selectionHandle(at point: CGPoint) -> SelectionHandle? { guard let handleGeometry = selectionHandleGeometry else { return nil } let startDistance = point.distance(to: handleGeometry.startKnobCenter) let endDistance = point.distance(to: handleGeometry.endKnobCenter) let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop let startMatched = startDistance <= maxDistance let endMatched = endDistance <= maxDistance switch (startMatched, endMatched) { case (true, true): return startDistance <= endDistance ? .start : .end case (true, false): return .start case (false, true): return .end default: return nil } } func selectionContains(_ point: CGPoint) -> Bool { selectionRects.contains { rect in rect.insetBy(dx: -6, dy: -8).contains(point) } } private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? { guard let firstRect = selectionRects.first, let lastRect = selectionRects.last else { return nil } let startKnobCenter = CGPoint( x: firstRect.minX, y: firstRect.minY - selectionHandleKnobRadius ) let endKnobCenter = CGPoint( x: lastRect.maxX, y: lastRect.maxY + selectionHandleKnobRadius ) return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter) } private func drawSelectionHandles(in context: CGContext) { guard let firstRect = selectionRects.first, let lastRect = selectionRects.last else { return } context.saveGState() context.setFillColor(selectionHandleColor.cgColor) let stemHalfWidth = selectionHandleStemWidth / 2 let startStem = CGRect( x: firstRect.minX - stemHalfWidth, y: firstRect.minY - selectionHandleKnobRadius * 2, width: selectionHandleStemWidth, height: firstRect.height + selectionHandleKnobRadius * 2 ) context.fill(startStem) let startKnob = CGRect( x: firstRect.minX - selectionHandleKnobRadius, y: firstRect.minY - selectionHandleKnobRadius * 2, width: selectionHandleKnobRadius * 2, height: selectionHandleKnobRadius * 2 ) context.fillEllipse(in: startKnob) let endStem = CGRect( x: lastRect.maxX - stemHalfWidth, y: lastRect.minY, width: selectionHandleStemWidth, height: lastRect.height + selectionHandleKnobRadius * 2 ) context.fill(endStem) let endKnob = CGRect( x: lastRect.maxX - selectionHandleKnobRadius, y: lastRect.maxY, width: selectionHandleKnobRadius * 2, height: selectionHandleKnobRadius * 2 ) context.fillEllipse(in: endKnob) context.restoreGState() } private func invalidateStaticContent() { cachedStaticImage = nil needsStaticContentRedraw = true setNeedsDisplay() } private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? { guard bounds.width > 0, bounds.height > 0 else { return nil } let format = UIGraphicsImageRendererFormat.default() format.opaque = false let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format) return renderer.image { _ in guard let staticContext = UIGraphicsGetCurrentContext() else { return } layoutFrame.draw(in: staticContext, options: drawOptions) } } } private extension CGPoint { func distance(to point: CGPoint) -> CGFloat { hypot(x - point.x, y - point.y) } } #endif