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 attributedDisplayContent: NSAttributedString? { 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 { if let attributedDisplayContent { drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame) } layoutFrame.draw(in: context, options: drawOptions) } drawSelection(in: context) context.restoreGState() } override func layoutSubviews() { super.layoutSubviews() if cachedStaticBoundsSize != bounds.size { invalidateStaticContent() } } private func drawHighlights( in context: CGContext, attributedString: NSAttributedString, layoutFrame: DTCoreTextLayoutFrame ) { let fullRange = NSRange(location: 0, length: attributedString.length) attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in guard let color = value as? UIColor else { return } let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame) color.setFill() for rect in rects { context.fill(rect) } } attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in guard let color = value as? UIColor else { return } let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame) color.setStroke() context.setLineWidth(2) for rect in rects { let y = rect.maxY - 1 context.move(to: CGPoint(x: rect.minX, y: y)) context.addLine(to: CGPoint(x: rect.maxX, y: y)) context.strokePath() } } } private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] { var rects: [CGRect] = [] guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects } for line in lines { let lineRange = line.stringRange() let overlap = NSIntersectionRange(range, lineRange) guard overlap.length > 0 else { continue } let startX = line.offset(forStringIndex: overlap.location) let endX = line.offset(forStringIndex: overlap.location + overlap.length) let rect = CGRect( x: line.baselineOrigin.x + startX, y: line.baselineOrigin.y - line.ascent, width: endX - startX, height: line.ascent + line.descent ) rects.append(rect) } return rects } 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 } if let attributedDisplayContent { drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame) } layoutFrame.draw(in: staticContext, options: drawOptions) } } } private extension CGPoint { func distance(to point: CGPoint) -> CGFloat { hypot(x - point.x, y - point.y) } } #endif