import UIKit #if canImport(DTCoreText) import DTCoreText #endif // MARK: - 页面交互控制器 /// 页面交互控制器,负责文本层面的坐标映射与选择逻辑 /// 在 DTCoreText 排版结果和用户交互之间充当桥梁 /// 提供字符索引查找、选择范围计算、选择矩形计算等功能 final class RDEPUBPageInteractionController { var snapshot: RDEPUBPageLayoutSnapshot? private var dtLayoutFrame: DTCoreTextLayoutFrame? #if canImport(DTCoreText) func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) { dtLayoutFrame = layoutFrame if let layoutFrame, let page { snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page) } else { snapshot = nil } } #endif // MARK: - Hit Testing func characterIndex(at point: CGPoint) -> Int? { guard let snapshot else { return nil } // Attachment rect priority (6pt inset for easier tapping) for attachment in snapshot.attachments { if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) { return attachment.stringRange.location } } guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil } #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } let relativePoint = CGPoint( x: point.x - line.baselineOrigin.x, y: point.y - line.baselineOrigin.y ) let idx = dtLine.stringIndex(forPosition: relativePoint) guard idx != NSNotFound, idx >= 0 else { return nil } return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange) #else return nil #endif } // MARK: - Selection Range func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? { guard let start = characterIndex(at: startPoint), let end = characterIndex(at: endPoint) else { return nil } let lower = min(start, end) let upper = max(start, end) return NSRange(location: lower, length: max(upper - lower, 1)) } // MARK: - Selection Rects func selectionRects(for absoluteRange: NSRange) -> [CGRect] { guard let snapshot else { return [] } var rects: [CGRect] = [] for line in snapshot.lines { let overlap = NSIntersectionRange(line.stringRange, absoluteRange) guard overlap.length > 0 else { continue } #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } let startX = dtLine.offset(forStringIndex: overlap.location) let endIdx = overlap.location + overlap.length let endX = dtLine.offset(forStringIndex: endIdx) #else let startX: CGFloat = 0 let endX: CGFloat = line.frame.width #endif let rect = CGRect( x: line.baselineOrigin.x + startX, y: line.baselineOrigin.y - line.ascent, width: max(endX - startX, 2), height: line.ascent + line.descent ) rects.append(rect) } return mergeAdjacentRects(rects) } func firstRect(for absoluteRange: NSRange) -> CGRect? { selectionRects(for: absoluteRange).first } func lastRect(for absoluteRange: NSRange) -> CGRect? { selectionRects(for: absoluteRange).last } func boundingRect(for absoluteRange: NSRange) -> CGRect? { let rects = selectionRects(for: absoluteRange) guard var rect = rects.first else { return nil } for next in rects.dropFirst() { rect = rect.union(next) } return rect } func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? { guard let first = firstRect(for: absoluteRange), let last = lastRect(for: absoluteRange) else { return boundingRect(for: absoluteRange) } let minX = min(first.minX, last.minX) let maxX = max(first.maxX, last.maxX) let minY = min(first.minY, last.minY) let maxY = max(first.maxY, last.maxY) return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2)) } // MARK: - Caret Rect func caretRect(at index: Int) -> CGRect? { guard let snapshot else { return nil } guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else { return nil } #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } let offsetX = dtLine.offset(forStringIndex: index) #else let offsetX: CGFloat = 0 #endif return CGRect( x: line.baselineOrigin.x + offsetX - 1, y: line.baselineOrigin.y - line.ascent, width: 2, height: line.ascent + line.descent ) } // MARK: - Private Helpers private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? { var bestLine: RDEPUBPageLine? var bestDistance: CGFloat = .greatestFiniteMagnitude for line in lines { let lineBottom = line.baselineOrigin.y + line.descent let lineTop = line.baselineOrigin.y - line.ascent if point.y >= lineTop && point.y <= lineBottom { return line } let lineMidY = (lineTop + lineBottom) / 2 let distance = abs(point.y - lineMidY) if distance < bestDistance { bestDistance = distance bestLine = line } } return bestLine } private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int { var result = idx if result < lineRange.location { result = lineRange.location } let lineEnd = lineRange.location + lineRange.length if result >= lineEnd { result = max(lineEnd - 1, lineRange.location) } return result } #if canImport(DTCoreText) private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { guard let dtLayoutFrame else { return nil } return dtLayoutFrame.lineContaining(UInt(range.location)) } #endif private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] { guard rects.count > 1 else { return rects } let sorted = rects.sorted { a, b in if abs(a.origin.y - b.origin.y) < 1 { return a.origin.x < b.origin.x } return a.origin.y < b.origin.y } var merged: [CGRect] = [sorted[0]] for rect in sorted.dropFirst() { let last = merged[merged.count - 1] if abs(rect.origin.y - last.origin.y) < 1, rect.origin.x <= last.maxX + 2 { merged[merged.count - 1] = last.union(rect) } else { merged.append(rect) } } return merged } }