- 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
224 lines
7.3 KiB
Swift
224 lines
7.3 KiB
Swift
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
|
||
|
||
/// 将视图坐标系的触摸点转为字符索引(对齐 WXRead 的 stringIndexForPoint:)
|
||
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
|
||
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
|
||
return characterIndex(at: localPoint)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|