213 lines
6.8 KiB
Swift
213 lines
6.8 KiB
Swift
import UIKit
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
#endif
|
|
|
|
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
|
|
}
|
|
}
|