refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
/// Chapter-level shared display content and layouter
|
||||
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1 / P1-3).
|
||||
///
|
||||
/// One entry holds the chapter-length display string (theme text color and
|
||||
/// dark-image adjustment injected once over the full chapter at build time)
|
||||
/// plus the DTCoreTextLayouter wrapping its framesetter. Page views reference
|
||||
/// the shared string and request per-page layout frames; they must never
|
||||
/// mutate the shared string — the framesetter and any live layout frame hold
|
||||
/// it. Page-level decoration (highlights, search, selection) is drawn by the
|
||||
/// overlay views instead.
|
||||
///
|
||||
/// Main-thread only.
|
||||
final class RDEPUBChapterDisplayContentCache {
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
struct Entry {
|
||||
let content: NSAttributedString
|
||||
let layouter: DTCoreTextLayouter?
|
||||
fileprivate let signature: Signature
|
||||
}
|
||||
|
||||
fileprivate struct Signature: Equatable {
|
||||
let chapterContentID: ObjectIdentifier
|
||||
let contentLength: Int
|
||||
let textColor: UIColor
|
||||
let backgroundColor: UIColor
|
||||
let darkImageAdjustmentEnabled: Bool
|
||||
let darkImageBlendRatio: CGFloat
|
||||
|
||||
init(page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) {
|
||||
chapterContentID = ObjectIdentifier(page.chapterContent)
|
||||
contentLength = page.chapterContent.length
|
||||
textColor = configuration.theme.contentTextColor
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
darkImageAdjustmentEnabled = configuration.darkImageAdjustmentEnabled
|
||||
darkImageBlendRatio = configuration.darkImageBlendRatio
|
||||
}
|
||||
}
|
||||
|
||||
/// Current chapter plus one adjacent chapter during page-curl/scroll
|
||||
/// transitions across a chapter boundary.
|
||||
private static let capacity = 2
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
|
||||
private var accessOrder: [String] = []
|
||||
|
||||
func entry(for page: RDEPUBTextPage, configuration: RDEPUBReaderConfiguration) -> Entry {
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
let signature = Signature(page: page, configuration: configuration)
|
||||
if let cached = entries[page.href], cached.signature == signature {
|
||||
touch(page.href)
|
||||
return cached
|
||||
}
|
||||
let entry = Self.buildEntry(page: page, configuration: configuration, signature: signature)
|
||||
entries[page.href] = entry
|
||||
touch(page.href)
|
||||
evictIfNeeded()
|
||||
RDEPUBMemoryProbe.log("displayContentBuilt href=\(page.href) length=\(entry.content.length)")
|
||||
return entry
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
entries.removeAll()
|
||||
accessOrder.removeAll()
|
||||
}
|
||||
|
||||
private func touch(_ href: String) {
|
||||
accessOrder.removeAll { $0 == href }
|
||||
accessOrder.append(href)
|
||||
}
|
||||
|
||||
private func evictIfNeeded() {
|
||||
while accessOrder.count > Self.capacity {
|
||||
let evicted = accessOrder.removeFirst()
|
||||
entries.removeValue(forKey: evicted)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildEntry(
|
||||
page: RDEPUBTextPage,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
signature: Signature
|
||||
) -> Entry {
|
||||
let content = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||
let fullRange = NSRange(location: 0, length: content.length)
|
||||
_ = RDEPUBDarkImageAdjuster.adjustIfNeeded(
|
||||
content,
|
||||
in: fullRange,
|
||||
configuration: configuration
|
||||
)
|
||||
content.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
let layouter = DTCoreTextLayouter(attributedString: content)
|
||||
layouter?.shouldCacheLayoutFrames = false
|
||||
return Entry(content: content, layouter: layouter, signature: signature)
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
func removeAll() {}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
enum RDEPUBDarkImageAdjuster {
|
||||
|
||||
private static let imageCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 100
|
||||
cache.totalCostLimit = 52_428_800 // 50 MB
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static func imageCost(of image: UIImage) -> Int {
|
||||
let scale = image.scale
|
||||
let width = Int(image.size.width * scale)
|
||||
let height = Int(image.size.height * scale)
|
||||
// 4 bytes per pixel (RGBA)
|
||||
return width * height * 4
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func adjustIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
in range: NSRange? = nil,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) -> NSMutableAttributedString {
|
||||
guard configuration.darkImageAdjustmentEnabled,
|
||||
configuration.darkImageBlendRatio > 0,
|
||||
configuration.theme.contentBackgroundColor.rd_isDarkBackground else {
|
||||
return content
|
||||
}
|
||||
|
||||
let fullRange = NSRange(location: 0, length: content.length)
|
||||
let targetRange = range.map { NSIntersectionRange($0, fullRange) } ?? fullRange
|
||||
content.enumerateAttribute(.attachment, in: targetRange) { value, range, _ in
|
||||
guard let attachment = value as? DTImageTextAttachment,
|
||||
!isCoverAttachment(attachment),
|
||||
let image = attachment.image,
|
||||
shouldAdjust(image) else { return }
|
||||
|
||||
let adjustedAttachment = DTImageTextAttachment()
|
||||
adjustedAttachment.image = adjustedImage(
|
||||
image,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor,
|
||||
blendRatio: configuration.darkImageBlendRatio,
|
||||
cacheKey: cacheKey(for: attachment, image: image, configuration: configuration)
|
||||
)
|
||||
adjustedAttachment.originalSize = attachment.originalSize
|
||||
adjustedAttachment.displaySize = attachment.displaySize
|
||||
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
|
||||
adjustedAttachment.contentURL = attachment.contentURL
|
||||
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
|
||||
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
|
||||
adjustedAttachment.attributes = attachment.attributes
|
||||
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
|
||||
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
|
||||
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
|
||||
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
|
||||
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
|
||||
}
|
||||
|
||||
private static func shouldAdjust(_ image: UIImage) -> Bool {
|
||||
image.size.width >= 80 && image.size.height >= 80
|
||||
}
|
||||
|
||||
private static func cacheKey(
|
||||
for attachment: DTImageTextAttachment,
|
||||
image: UIImage,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
) -> NSString {
|
||||
let source = attachment.contentURL?.absoluteString
|
||||
?? "\(Unmanaged.passUnretained(image).toOpaque())"
|
||||
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.rd_cssString)|\(configuration.darkImageBlendRatio)" as NSString
|
||||
}
|
||||
|
||||
private static func adjustedImage(
|
||||
_ image: UIImage,
|
||||
backgroundColor: UIColor,
|
||||
blendRatio: CGFloat,
|
||||
cacheKey: NSString
|
||||
) -> UIImage {
|
||||
if let cached = imageCache.object(forKey: cacheKey) { return cached }
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
format.opaque = false
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
let adjusted = renderer.image { context in
|
||||
image.draw(in: CGRect(origin: .zero, size: image.size))
|
||||
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
|
||||
context.cgContext.setBlendMode(.sourceAtop)
|
||||
context.fill(CGRect(origin: .zero, size: image.size))
|
||||
}
|
||||
imageCache.setObject(adjusted, forKey: cacheKey, cost: imageCost(of: adjusted))
|
||||
return adjusted
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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
|
||||
|
||||
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 }
|
||||
|
||||
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
|
||||
)
|
||||
// The layout frame is built in chapter context, so DTCoreText string
|
||||
// indices are chapter-absolute already.
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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(max(range.location, 0)))
|
||||
}
|
||||
#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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBPageLine {
|
||||
|
||||
let stringRange: NSRange
|
||||
|
||||
let frame: CGRect
|
||||
|
||||
let baselineOrigin: CGPoint
|
||||
|
||||
let ascent: CGFloat
|
||||
|
||||
let descent: CGFloat
|
||||
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
struct RDEPUBPageRun {
|
||||
|
||||
let stringRange: NSRange
|
||||
|
||||
let frame: CGRect
|
||||
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
struct RDEPUBPageAttachment {
|
||||
|
||||
let stringRange: NSRange
|
||||
|
||||
let frame: CGRect
|
||||
|
||||
let displaySize: CGSize
|
||||
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let lines: [RDEPUBPageLine]
|
||||
|
||||
let runs: [RDEPUBPageRun]
|
||||
|
||||
let attachments: [RDEPUBPageAttachment]
|
||||
|
||||
let pageContentRange: NSRange
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
let layoutFrame: DTCoreTextLayoutFrame
|
||||
#endif
|
||||
|
||||
var contentBounds: CGRect {
|
||||
let rects = lines.map(\.frame) + attachments.map(\.frame)
|
||||
guard var bounds = rects.first else { return .zero }
|
||||
for rect in rects.dropFirst() {
|
||||
bounds = bounds.union(rect)
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
|
||||
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
||||
}
|
||||
|
||||
func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
|
||||
runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
||||
}
|
||||
|
||||
func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
|
||||
runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
|
||||
}
|
||||
|
||||
func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
|
||||
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
||||
}
|
||||
|
||||
func line(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageLine? {
|
||||
lines.first { line in
|
||||
let lineRect = CGRect(
|
||||
x: line.frame.minX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: max(line.frame.width, 1),
|
||||
height: line.ascent + line.descent + line.leading
|
||||
)
|
||||
return lineRect.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
func run(at point: CGPoint, hitSlop: CGFloat = 4) -> RDEPUBPageRun? {
|
||||
runs.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
||||
}
|
||||
|
||||
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
|
||||
decorations.filter { decoration in
|
||||
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
|
||||
}
|
||||
}
|
||||
|
||||
func absoluteRange(at point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> NSRange? {
|
||||
if let attachment = attachment(at: point) {
|
||||
return attachment.stringRange
|
||||
}
|
||||
if let run = run(at: point) {
|
||||
return run.stringRange
|
||||
}
|
||||
let hitDecorations = rects(containing: point, in: decorations)
|
||||
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
|
||||
lhs.absoluteRange.length < rhs.absoluteRange.length
|
||||
}) {
|
||||
return mostSpecific.absoluteRange
|
||||
}
|
||||
return line(at: point)?.stringRange
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func build(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
page: RDEPUBTextPage
|
||||
) -> RDEPUBPageLayoutSnapshot? {
|
||||
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The layout frame is produced in chapter context, so DTCoreText
|
||||
// string ranges are already chapter-absolute.
|
||||
var lines: [RDEPUBPageLine] = []
|
||||
var runs: [RDEPUBPageRun] = []
|
||||
var attachments: [RDEPUBPageAttachment] = []
|
||||
|
||||
for dtLine in dtLines {
|
||||
let lineRange = dtLine.stringRange()
|
||||
let line = RDEPUBPageLine(
|
||||
stringRange: lineRange,
|
||||
frame: dtLine.frame,
|
||||
baselineOrigin: dtLine.baselineOrigin,
|
||||
ascent: dtLine.ascent,
|
||||
descent: dtLine.descent,
|
||||
leading: dtLine.leading
|
||||
)
|
||||
lines.append(line)
|
||||
|
||||
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
||||
for run in glyphRuns {
|
||||
let runRange = run.stringRange()
|
||||
let isAttachment = run.attachment != nil
|
||||
runs.append(
|
||||
RDEPUBPageRun(
|
||||
stringRange: runRange,
|
||||
frame: run.frame,
|
||||
isAttachment: isAttachment
|
||||
)
|
||||
)
|
||||
|
||||
guard isAttachment else { continue }
|
||||
let metadata = attachmentMetadata(
|
||||
for: runRange,
|
||||
on: page
|
||||
)
|
||||
attachments.append(
|
||||
RDEPUBPageAttachment(
|
||||
stringRange: runRange,
|
||||
frame: run.frame,
|
||||
displaySize: run.frame.size,
|
||||
placement: metadata.placement,
|
||||
kind: metadata.kind
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
|
||||
return RDEPUBPageLayoutSnapshot(
|
||||
page: page,
|
||||
lines: lines,
|
||||
runs: runs,
|
||||
attachments: attachments,
|
||||
pageContentRange: visibleRange,
|
||||
layoutFrame: layoutFrame
|
||||
)
|
||||
}
|
||||
|
||||
private static func attachmentMetadata(
|
||||
for range: NSRange,
|
||||
on page: RDEPUBTextPage
|
||||
) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
|
||||
guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
|
||||
? page.metadata.attachmentPlacements[attachmentIndex]
|
||||
: nil
|
||||
let metadataKind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
||||
? page.metadata.attachmentKinds[attachmentIndex]
|
||||
: nil
|
||||
let resolvedKind = attachmentKind(at: range, on: page) ?? metadataKind
|
||||
return (placement, resolvedKind)
|
||||
}
|
||||
|
||||
private static func attachmentKind(
|
||||
at range: NSRange,
|
||||
on page: RDEPUBTextPage
|
||||
) -> RDEPUBTextAttachmentKind? {
|
||||
guard range.location >= 0,
|
||||
range.location < page.chapterContent.length else {
|
||||
return nil
|
||||
}
|
||||
let attributes = page.chapterContent.attributes(at: range.location, effectiveRange: nil)
|
||||
return RDEPUBAttachmentNormalizer.attachmentKind(for: attributes)
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBSelectionLoupeView: UIView {
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
private let magnification: CGFloat = 1.45
|
||||
|
||||
private let captureSize = CGSize(width: 84, height: 84)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
|
||||
isUserInteractionEnabled = false
|
||||
backgroundColor = .clear
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.18
|
||||
layer.shadowRadius = 10
|
||||
layer.shadowOffset = CGSize(width: 0, height: 5)
|
||||
|
||||
imageView.frame = bounds
|
||||
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
imageView.layer.cornerRadius = bounds.width / 2
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.layer.borderWidth = 1.5
|
||||
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
|
||||
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
|
||||
|
||||
let targetCenter = CGPoint(
|
||||
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
|
||||
y: min(
|
||||
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
|
||||
hostBounds.maxY - bounds.height / 2
|
||||
)
|
||||
)
|
||||
|
||||
center = targetCenter
|
||||
if isHidden {
|
||||
alpha = 0
|
||||
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
|
||||
isHidden = false
|
||||
UIView.animate(withDuration: 0.12) {
|
||||
self.alpha = 1
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
guard !isHidden else { return }
|
||||
isHidden = true
|
||||
alpha = 0
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: captureSize)
|
||||
return renderer.image { context in
|
||||
let cgContext = context.cgContext
|
||||
cgContext.setFillColor(UIColor.systemBackground.cgColor)
|
||||
cgContext.fill(CGRect(origin: .zero, size: captureSize))
|
||||
cgContext.translateBy(
|
||||
x: captureSize.width / 2 - focusPoint.x * magnification,
|
||||
y: captureSize.height / 2 - focusPoint.y * magnification
|
||||
)
|
||||
cgContext.scaleBy(x: magnification, y: magnification)
|
||||
sourceView.layer.render(in: cgContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
|
||||
enum Kind: String {
|
||||
|
||||
case selection
|
||||
|
||||
case highlight
|
||||
|
||||
case underline
|
||||
|
||||
case search
|
||||
|
||||
case activeSearch
|
||||
|
||||
case locate
|
||||
}
|
||||
|
||||
var kind: Kind
|
||||
|
||||
var absoluteRange: NSRange
|
||||
|
||||
var rects: [CGRect]
|
||||
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
class RDEPUBSelectionOverlayView: UIView {
|
||||
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
private(set) var selectionRange: NSRange?
|
||||
|
||||
private var selectionRects: [CGRect] = []
|
||||
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
|
||||
private let selectionVerticalAdjustment: CGFloat = -1
|
||||
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
|
||||
self.page = page
|
||||
self.snapshot = snapshot
|
||||
self.selectionColor = selectionColor
|
||||
selectionRange = nil
|
||||
decorations = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
func updateSelection(absoluteRange: NSRange?) {
|
||||
selectionRange = absoluteRange
|
||||
selectionRects = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
|
||||
selectionRange = absoluteRange
|
||||
selectionRects = rects
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||
self.decorations = decorations
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
updateSelection(absoluteRange: nil)
|
||||
}
|
||||
|
||||
func absoluteRange(at point: CGPoint) -> NSRange? {
|
||||
if let selectionRange,
|
||||
selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
|
||||
return selectionRange
|
||||
}
|
||||
|
||||
if let snapshot,
|
||||
let absoluteRange = snapshot.absoluteRange(at: point, in: resolvedDecorations) {
|
||||
return absoluteRange
|
||||
}
|
||||
|
||||
for decoration in resolvedDecorations {
|
||||
for rect in decoration.rects {
|
||||
if rect.insetBy(dx: -4, dy: -4).contains(point) {
|
||||
return decoration.absoluteRange
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decorationSummary() -> String {
|
||||
let counts = Dictionary(grouping: resolvedDecorations, by: \.kind).mapValues(\.count)
|
||||
let selectionLabel = selectionRange.map(NSStringFromRange) ?? "none"
|
||||
let highlightCount = counts[.highlight, default: 0]
|
||||
let underlineCount = counts[.underline, default: 0]
|
||||
let searchCount = counts[.search, default: 0]
|
||||
let activeSearchCount = counts[.activeSearch, default: 0]
|
||||
let locateLabel = resolvedDecorations.first(where: { $0.kind == .locate }).map { NSStringFromRange($0.absoluteRange) } ?? "none"
|
||||
return [
|
||||
"selection \(selectionLabel)",
|
||||
"highlight \(highlightCount)",
|
||||
"underline \(underlineCount)",
|
||||
"search \(searchCount)",
|
||||
"activeSearch \(activeSearchCount)",
|
||||
"locate \(locateLabel)"
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
|
||||
for decoration in resolvedDecorations {
|
||||
switch decoration.kind {
|
||||
case .underline:
|
||||
context.setStrokeColor(decoration.color.cgColor)
|
||||
context.setLineWidth(2)
|
||||
for underlineRect in decoration.rects {
|
||||
let y = underlineRect.maxY - 1
|
||||
context.move(to: CGPoint(x: underlineRect.minX, y: y))
|
||||
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
|
||||
context.strokePath()
|
||||
}
|
||||
default:
|
||||
context.setFillColor(decoration.color.cgColor)
|
||||
for selectionRect in decoration.rects {
|
||||
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: selectionVerticalAdjustment)
|
||||
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
|
||||
context.addPath(path.cgPath)
|
||||
context.fillPath()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
|
||||
guard page != nil else { return [] }
|
||||
|
||||
var result = decorations.filter { !$0.rects.isEmpty }
|
||||
|
||||
if let selectionRange, !selectionRects.isEmpty {
|
||||
result.append(
|
||||
RDEPUBTextOverlayDecoration(
|
||||
kind: .selection,
|
||||
absoluteRange: selectionRange,
|
||||
rects: selectionRects,
|
||||
color: selectionColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
|
||||
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||
|
||||
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
|
||||
func applyHighlights(
|
||||
_ highlights: [RDEPUBHighlight],
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for highlight in highlights where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - contentBaseOffset,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?,
|
||||
contentBaseOffset: Int
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
for match in searchState.matches {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeSearchColor : normalSearchColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
|
||||
func buildDecorations(
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight],
|
||||
searchState: RDEPUBSearchState?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
|
||||
var background: [RDEPUBTextOverlayDecoration] = []
|
||||
var foreground: [RDEPUBTextOverlayDecoration] = []
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
if let searchState {
|
||||
for match in searchState.matches {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||
let color = isActive ? activeSearchColor : normalSearchColor
|
||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||
}
|
||||
}
|
||||
|
||||
for highlight in highlights where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
absoluteRange: absoluteRange,
|
||||
rects: rects,
|
||||
color: color
|
||||
)
|
||||
|
||||
if decoration.kind == .underline {
|
||||
foreground.append(decoration)
|
||||
} else {
|
||||
background.append(decoration)
|
||||
}
|
||||
}
|
||||
|
||||
return (background, foreground)
|
||||
}
|
||||
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBTextContentInteractionCoordinator: NSObject {
|
||||
|
||||
enum SelectionInteractionState: Equatable {
|
||||
case idle
|
||||
case selectionPending
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
struct Dependencies {
|
||||
let hasRenderableContent: () -> Bool
|
||||
let currentSelectionProvider: () -> RDEPUBSelection?
|
||||
let isSelectionControllerSelecting: () -> Bool
|
||||
let hasActiveSelection: () -> Bool
|
||||
let selectionHandleAtPoint: (CGPoint) -> RDEPUBTextSelectionController.BoundaryHandle?
|
||||
let selectionContainsPoint: (CGPoint) -> Bool
|
||||
let renderPointForGesture: (UIGestureRecognizer) -> CGPoint
|
||||
let renderPointForTouch: (UITouch) -> CGPoint
|
||||
let performLongPressSelection: (UILongPressGestureRecognizer) -> Void
|
||||
let performPanSelection: (UIPanGestureRecognizer) -> Void
|
||||
let adjustSelection: (RDEPUBTextSelectionController.BoundaryHandle, CGPoint) -> Void
|
||||
let presentLoupeAtPoint: (CGPoint) -> Void
|
||||
let dismissLoupe: () -> Void
|
||||
let showSelectionMenu: () -> Void
|
||||
let hideSelectionMenu: () -> Void
|
||||
let selectionTapSuppressionDidChange: (Bool) -> Void
|
||||
let selectionPagingSuppressionDidChange: (Bool) -> Void
|
||||
}
|
||||
|
||||
private let dependencies: Dependencies
|
||||
|
||||
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
|
||||
|
||||
private(set) var interactionState: SelectionInteractionState = .idle
|
||||
|
||||
var isInteractionInProgress: Bool {
|
||||
interactionState != .idle
|
||||
}
|
||||
|
||||
init(dependencies: Dependencies) {
|
||||
self.dependencies = dependencies
|
||||
super.init()
|
||||
}
|
||||
|
||||
func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
dependencies.performLongPressSelection(gesture)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.selecting)
|
||||
dependencies.hideSelectionMenu()
|
||||
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
|
||||
case .changed:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
dependencies.presentLoupeAtPoint(dependencies.renderPointForGesture(gesture))
|
||||
case .ended:
|
||||
activeSelectionHandle = nil
|
||||
dependencies.dismissLoupe()
|
||||
dependencies.showSelectionMenu()
|
||||
case .cancelled, .failed:
|
||||
activeSelectionHandle = nil
|
||||
dependencies.dismissLoupe()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
let point = dependencies.renderPointForGesture(gesture)
|
||||
|
||||
if gesture.state == .began, activeSelectionHandle == nil,
|
||||
let handle = dependencies.selectionHandleAtPoint(point) {
|
||||
activeSelectionHandle = handle
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
dependencies.hideSelectionMenu()
|
||||
dependencies.presentLoupeAtPoint(point)
|
||||
}
|
||||
|
||||
if let activeSelectionHandle {
|
||||
dependencies.adjustSelection(activeSelectionHandle, point)
|
||||
dependencies.presentLoupeAtPoint(point)
|
||||
} else {
|
||||
dependencies.performPanSelection(gesture)
|
||||
if dependencies.isSelectionControllerSelecting() {
|
||||
dependencies.presentLoupeAtPoint(point)
|
||||
}
|
||||
}
|
||||
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(
|
||||
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
|
||||
)
|
||||
dependencies.dismissLoupe()
|
||||
dependencies.showSelectionMenu()
|
||||
case .cancelled, .failed:
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(
|
||||
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
|
||||
)
|
||||
dependencies.dismissLoupe()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func selectionControllerStateDidChange(_ state: RDEPUBTextSelectionController.InteractionState) {
|
||||
switch state {
|
||||
case .idle:
|
||||
if dependencies.currentSelectionProvider() == nil, activeSelectionHandle == nil {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
case .selecting:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
case .selectionActive:
|
||||
updateSelectionInteractionState(
|
||||
dependencies.currentSelectionProvider() == nil ? .idle : .selectionActive
|
||||
)
|
||||
case .adjustingHandle:
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
}
|
||||
}
|
||||
|
||||
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard interactionState == .idle,
|
||||
activeSelectionHandle == nil,
|
||||
dependencies.hasRenderableContent(),
|
||||
let touch = touches.first else {
|
||||
return
|
||||
}
|
||||
|
||||
let point = dependencies.renderPointForTouch(touch)
|
||||
guard dependencies.selectionHandleAtPoint(point) == nil else { return }
|
||||
updateSelectionInteractionState(.selectionPending)
|
||||
}
|
||||
|
||||
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer {
|
||||
if dependencies.isSelectionControllerSelecting() {
|
||||
return true
|
||||
}
|
||||
let point = dependencies.renderPointForGesture(gestureRecognizer)
|
||||
return dependencies.selectionHandleAtPoint(point) != nil
|
||||
}
|
||||
|
||||
if gestureRecognizer is UILongPressGestureRecognizer {
|
||||
guard dependencies.hasRenderableContent() else {
|
||||
return true
|
||||
}
|
||||
let point = dependencies.renderPointForGesture(gestureRecognizer)
|
||||
if dependencies.selectionHandleAtPoint(point) != nil {
|
||||
return false
|
||||
}
|
||||
if dependencies.hasActiveSelection(), dependencies.selectionContainsPoint(point) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard dependencies.hasRenderableContent() else {
|
||||
return true
|
||||
}
|
||||
|
||||
let point = dependencies.renderPointForTouch(touch)
|
||||
if let handle = dependencies.selectionHandleAtPoint(point) {
|
||||
if gestureRecognizer is UIPanGestureRecognizer {
|
||||
activeSelectionHandle = handle
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
dependencies.hideSelectionMenu()
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UITapGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
gestureRecognizer is UILongPressGestureRecognizer || gestureRecognizer is UIPanGestureRecognizer
|
||||
}
|
||||
|
||||
func reset() {
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
dependencies.dismissLoupe()
|
||||
}
|
||||
|
||||
private func resetSelectionPendingIfNeeded() {
|
||||
guard interactionState == .selectionPending else { return }
|
||||
if dependencies.currentSelectionProvider() != nil {
|
||||
updateSelectionInteractionState(.selectionActive)
|
||||
} else {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
|
||||
let previousTapSuppressed = interactionState != .idle
|
||||
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
|
||||
let previousState = interactionState
|
||||
interactionState = state
|
||||
let currentTapSuppressed = interactionState != .idle
|
||||
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: interactionState)
|
||||
if previousState != state {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"TextContentInteraction.state",
|
||||
"transition \(previousState) -> \(state) tapSuppressed=\(currentTapSuppressed) pagingSuppressed=\(currentPagingSuppressed)"
|
||||
)
|
||||
}
|
||||
if previousTapSuppressed != currentTapSuppressed {
|
||||
dependencies.selectionTapSuppressionDidChange(currentTapSuppressed)
|
||||
}
|
||||
if previousPagingSuppressed != currentPagingSuppressed {
|
||||
dependencies.selectionPagingSuppressionDidChange(currentPagingSuppressed)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
|
||||
switch state {
|
||||
case .idle, .selectionPending, .selectionActive:
|
||||
return false
|
||||
case .selecting, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// Debug-only detector for pagination/display metric mismatches, enabled by
|
||||
/// the `--demo-pagination-validate` launch argument.
|
||||
///
|
||||
/// For every displayed text page it re-wraps the chapter text from the page
|
||||
/// start at the display width (same CoreText engine the paginator used, in
|
||||
/// chapter context) and compares the resulting line breaks against both the
|
||||
/// page range and the lines actually drawn. Two failure classes:
|
||||
///
|
||||
/// - `STALE-RANGE`: the page range does not end on a line boundary of the
|
||||
/// current metrics — the range was produced under different metrics than
|
||||
/// the ones on screen (stale page table).
|
||||
/// - `DISPLAY-DIVERGE`: the range is fine, but the drawn lines break at
|
||||
/// different offsets than the in-context wrap — the display-side content
|
||||
/// transform (e.g. continuation-paragraph normalization) changed wrapping.
|
||||
enum RDEPUBTextPageBoundaryValidator {
|
||||
|
||||
static let isEnabled = ProcessInfo.processInfo.arguments.contains("--demo-pagination-validate")
|
||||
|
||||
/// Extra characters wrapped past the page end so the probe can see the
|
||||
/// line that a mid-line page boundary cuts through.
|
||||
private static let probeTailLength = 400
|
||||
|
||||
static func validate(
|
||||
page: RDEPUBTextPage,
|
||||
displayLayoutFrame: DTCoreTextLayoutFrame,
|
||||
displayContent: NSAttributedString?,
|
||||
displayBounds: CGRect
|
||||
) {
|
||||
guard isEnabled else { return }
|
||||
let chapter = page.chapterContent
|
||||
let pageStart = page.contentRange.location
|
||||
let pageEnd = page.contentRange.location + page.contentRange.length
|
||||
guard page.contentRange.length > 0,
|
||||
pageStart >= 0,
|
||||
pageEnd <= chapter.length,
|
||||
displayBounds.width > 0 else { return }
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: chapter) else { return }
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
let probeLength = min(chapter.length - pageStart, page.contentRange.length + probeTailLength)
|
||||
let probeRect = CGRect(x: 0, y: 0, width: displayBounds.width, height: 4_000_000)
|
||||
guard let probeFrame = layouter.layoutFrame(
|
||||
with: probeRect,
|
||||
range: NSRange(location: pageStart, length: probeLength)
|
||||
), let probeLines = probeFrame.lines as? [DTCoreTextLayoutLine] else { return }
|
||||
|
||||
let probeRanges = probeLines.map { $0.stringRange() }
|
||||
|
||||
// Class A: the page must end on a line boundary of the current wrap
|
||||
// (unless it is the chapter's last page, which ends at chapter end).
|
||||
let isChapterLastPage = pageEnd >= chapter.length
|
||||
if !isChapterLastPage,
|
||||
!probeRanges.contains(where: { NSMaxRange($0) == pageEnd }),
|
||||
let cutLine = probeRanges.first(where: { NSLocationInRange(pageEnd - 1, $0) }) {
|
||||
let text = chapter.string as NSString
|
||||
let lineText = safeSubstring(text, cutLine)
|
||||
print("[PAGINATION-VALIDATE] STALE-RANGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) pageEnd=\(pageEnd) cutLine=\(NSStringFromRange(cutLine)) width=\(displayBounds.width) line=\"\(lineText)\"")
|
||||
}
|
||||
|
||||
// Class B: the drawn lines must break at the same offsets as the
|
||||
// in-context wrap. The display layout frame is built in chapter
|
||||
// context, so its string ranges are chapter-absolute.
|
||||
guard let displayLines = displayLayoutFrame.lines as? [DTCoreTextLayoutLine] else { return }
|
||||
for (index, displayLine) in displayLines.enumerated() {
|
||||
let displayRange = displayLine.stringRange()
|
||||
let displayEndInChapter = NSMaxRange(displayRange)
|
||||
guard displayEndInChapter < pageEnd else { break }
|
||||
guard index < probeRanges.count else { break }
|
||||
let probeEnd = NSMaxRange(probeRanges[index])
|
||||
if probeEnd != displayEndInChapter {
|
||||
let text = chapter.string as NSString
|
||||
let lineStartInChapter = displayRange.location
|
||||
let displayLineRangeInChapter = displayRange
|
||||
let isParagraphStart = lineStartInChapter == 0
|
||||
|| text.character(at: lineStartInChapter - 1) == 0x0A
|
||||
let chapterStyle = chapter.attribute(
|
||||
.paragraphStyle, at: lineStartInChapter, effectiveRange: nil
|
||||
) as? NSParagraphStyle
|
||||
let displayStyle = displayContent?.attribute(
|
||||
.paragraphStyle, at: displayRange.location, effectiveRange: nil
|
||||
) as? NSParagraphStyle
|
||||
let displayLineWidth = displayLine.frame.width
|
||||
let probeLineWidth = index < probeLines.count ? probeLines[index].frame.width : -1
|
||||
print("[PAGINATION-VALIDATE] DISPLAY-DIVERGE spine=\(page.spineIndex) page=\(page.pageIndexInChapter + 1)/\(page.totalPagesInChapter) lineIndex=\(index) displayLineEnd=\(displayEndInChapter) probeLineEnd=\(probeEnd) width=\(displayBounds.width) paraStart=\(isParagraphStart) chapterIndents=(\(chapterStyle?.firstLineHeadIndent ?? -1),\(chapterStyle?.headIndent ?? -1),tail:\(chapterStyle?.tailIndent ?? -1)) displayIndents=(\(displayStyle?.firstLineHeadIndent ?? -1),\(displayStyle?.headIndent ?? -1),tail:\(displayStyle?.tailIndent ?? -1)) displayLineWidth=\(displayLineWidth) probeLineWidth=\(probeLineWidth) displayLine=\"\(safeSubstring(text, displayLineRangeInChapter))\" displayBreak=\"…\(safeSubstring(text, NSRange(location: max(displayEndInChapter - 2, 0), length: min(4, text.length - max(displayEndInChapter - 2, 0)))))\" probeBreak=\"…\(safeSubstring(text, NSRange(location: max(probeEnd - 2, 0), length: min(4, text.length - max(probeEnd - 2, 0)))))\"")
|
||||
diagnoseDivergence(
|
||||
page: page,
|
||||
displayContent: displayContent,
|
||||
lineIndex: index,
|
||||
lineStartInChapter: lineStartInChapter,
|
||||
displayEndInChapter: displayEndInChapter,
|
||||
probeEnd: probeEnd,
|
||||
width: displayBounds.width
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrows a display/probe line-break divergence down to its cause by
|
||||
/// re-wrapping controlled variants and diffing attributes over the line.
|
||||
private static func diagnoseDivergence(
|
||||
page: RDEPUBTextPage,
|
||||
displayContent: NSAttributedString?,
|
||||
lineIndex: Int,
|
||||
lineStartInChapter: Int,
|
||||
displayEndInChapter: Int,
|
||||
probeEnd: Int,
|
||||
width: CGFloat
|
||||
) {
|
||||
let chapter = page.chapterContent
|
||||
|
||||
// Variant 1: the raw page substring with no display normalization.
|
||||
let rawSubstring = chapter.attributedSubstring(from: page.contentRange)
|
||||
let rawEnd = lineEnd(
|
||||
wrapping: rawSubstring,
|
||||
lineIndex: lineIndex,
|
||||
width: width
|
||||
).map { $0 + page.pageStartOffset }
|
||||
|
||||
// Variant 2: wrap the chapter from the start of the paragraph that
|
||||
// contains the diverging line (context = current paragraph only).
|
||||
let text = chapter.string as NSString
|
||||
let paragraphRange = text.paragraphRange(
|
||||
for: NSRange(location: lineStartInChapter, length: 0)
|
||||
)
|
||||
let paraString = chapter.attributedSubstring(
|
||||
from: NSRange(
|
||||
location: paragraphRange.location,
|
||||
length: min(chapter.length - paragraphRange.location, paragraphRange.length + probeTailLength)
|
||||
)
|
||||
)
|
||||
var paraEnd: Int?
|
||||
if let layouter = DTCoreTextLayouter(attributedString: paraString) {
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
let frame = layouter.layoutFrame(
|
||||
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
|
||||
range: NSRange(location: 0, length: paraString.length)
|
||||
)
|
||||
if let lines = frame?.lines as? [DTCoreTextLayoutLine] {
|
||||
let target = lineStartInChapter - paragraphRange.location
|
||||
if let matched = lines.first(where: { $0.stringRange().location == target }) {
|
||||
paraEnd = NSMaxRange(matched.stringRange()) + paragraphRange.location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[PAGINATION-VALIDATE] DIAGNOSE lineStart=\(lineStartInChapter) display=\(displayEndInChapter) probeFullContext=\(probeEnd) rawSubstringWrap=\(rawEnd ?? -1) paragraphContextWrap=\(paraEnd ?? -1)")
|
||||
|
||||
// Attribute diff between chapter text and display content over the
|
||||
// diverging line (through the longer of the two ends). The display
|
||||
// content is a chapter-length copy, so indices are shared.
|
||||
guard let displayContent else { return }
|
||||
let diffEnd = max(displayEndInChapter, probeEnd)
|
||||
var position = lineStartInChapter
|
||||
while position < diffEnd {
|
||||
guard position >= 0, position < displayContent.length, position < chapter.length else { break }
|
||||
var chapterRunRange = NSRange()
|
||||
let chapterAttrs = chapter.attributes(at: position, effectiveRange: &chapterRunRange)
|
||||
var displayRunRange = NSRange()
|
||||
let displayAttrs = displayContent.attributes(at: position, effectiveRange: &displayRunRange)
|
||||
|
||||
let keys = Set(chapterAttrs.keys).union(displayAttrs.keys)
|
||||
for key in keys {
|
||||
let lhs = chapterAttrs[key] as AnyObject?
|
||||
let rhs = displayAttrs[key] as AnyObject?
|
||||
if let lhs, let rhs, lhs.isEqual(rhs) { continue }
|
||||
if lhs == nil, rhs == nil { continue }
|
||||
print("[PAGINATION-VALIDATE] ATTR-DIFF pos=\(position) key=\(key.rawValue) chapter=\(describeAttr(lhs)) display=\(describeAttr(rhs))")
|
||||
}
|
||||
let nextPosition = min(
|
||||
NSMaxRange(chapterRunRange),
|
||||
NSMaxRange(displayRunRange)
|
||||
)
|
||||
guard nextPosition > position else { break }
|
||||
position = nextPosition
|
||||
}
|
||||
}
|
||||
|
||||
private static func describeAttr(_ value: AnyObject?) -> String {
|
||||
guard let value else { return "nil" }
|
||||
if let font = value as? UIFont {
|
||||
return "font(\(font.fontName),\(font.pointSize))"
|
||||
}
|
||||
if let style = value as? NSParagraphStyle {
|
||||
return "para(fli:\(style.firstLineHeadIndent),hi:\(style.headIndent),ti:\(style.tailIndent),lbm:\(style.lineBreakMode.rawValue),align:\(style.alignment.rawValue),lhm:\(style.lineHeightMultiple),ls:\(style.lineSpacing),min:\(style.minimumLineHeight),max:\(style.maximumLineHeight))"
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return "num(\(number))"
|
||||
}
|
||||
return String(describing: type(of: value))
|
||||
}
|
||||
|
||||
/// Wraps `content` page-locally and returns the chapter-relative end of
|
||||
/// line `lineIndex`, or nil if it cannot be produced.
|
||||
private static func lineEnd(
|
||||
wrapping content: NSAttributedString,
|
||||
lineIndex: Int,
|
||||
width: CGFloat
|
||||
) -> Int? {
|
||||
guard content.length > 0,
|
||||
let layouter = DTCoreTextLayouter(attributedString: content) else { return nil }
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
let frame = layouter.layoutFrame(
|
||||
with: CGRect(x: 0, y: 0, width: width, height: 4_000_000),
|
||||
range: NSRange(location: 0, length: content.length)
|
||||
)
|
||||
guard let lines = frame?.lines as? [DTCoreTextLayoutLine],
|
||||
lineIndex < lines.count else { return nil }
|
||||
return NSMaxRange(lines[lineIndex].stringRange())
|
||||
}
|
||||
|
||||
private static func safeSubstring(_ text: NSString, _ range: NSRange) -> String {
|
||||
guard range.location >= 0, NSMaxRange(range) <= text.length else { return "" }
|
||||
return text.substring(with: range)
|
||||
.replacingOccurrences(of: "\n", with: "⏎")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,3 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
|
||||
@@ -0,0 +1,213 @@
|
||||
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
|
||||
@@ -0,0 +1,57 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// Redistributes the leftover space at the bottom of a page into the gaps
|
||||
/// between lines so the last line sits flush with the content bottom edge
|
||||
/// (vertical justification), keeping page character ranges untouched.
|
||||
///
|
||||
/// Mutating each line's `baselineOrigin` is sufficient: DTCoreText derives
|
||||
/// line frames, glyph-run frames and attachment positions from it lazily,
|
||||
/// so drawing, selection, highlights and hit-testing all stay consistent.
|
||||
enum RDEPUBTextPageVerticalJustifier {
|
||||
|
||||
/// Leftover larger than this many typical line advances is kept as
|
||||
/// whitespace instead of being stretched: it usually comes from a whole
|
||||
/// block (image, table) pushed to the next page, and stretching would
|
||||
/// make the line spacing visibly sparse.
|
||||
static let maxStretchLineAdvanceRatio: CGFloat = 1.5
|
||||
|
||||
static func justify(
|
||||
_ layoutFrame: DTCoreTextLayoutFrame,
|
||||
contentHeight: CGFloat,
|
||||
isChapterLastPage: Bool,
|
||||
pixelScale: CGFloat
|
||||
) {
|
||||
guard !isChapterLastPage,
|
||||
contentHeight > 0,
|
||||
let lines = layoutFrame.lines as? [DTCoreTextLayoutLine],
|
||||
lines.count >= 2,
|
||||
let firstLine = lines.first,
|
||||
let lastLine = lines.last else {
|
||||
return
|
||||
}
|
||||
|
||||
let leftover = contentHeight - lastLine.frame.maxY
|
||||
guard leftover > 0.5 else { return }
|
||||
|
||||
let gapCount = CGFloat(lines.count - 1)
|
||||
let typicalAdvance = (lastLine.baselineOrigin.y - firstLine.baselineOrigin.y) / gapCount
|
||||
guard typicalAdvance > 0,
|
||||
leftover <= typicalAdvance * maxStretchLineAdvanceRatio else {
|
||||
return
|
||||
}
|
||||
|
||||
let scale = max(pixelScale, 1)
|
||||
for (index, line) in lines.enumerated() where index > 0 {
|
||||
// Round each cumulative shift down to the pixel grid so glyphs
|
||||
// stay sharp and the last line never overshoots the bottom edge.
|
||||
let shift = floor(leftover * CGFloat(index) / gapCount * scale) / scale
|
||||
var origin = line.baselineOrigin
|
||||
origin.y += shift
|
||||
line.baselineOrigin = origin
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,388 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
enum BoundaryHandle {
|
||||
case start
|
||||
case end
|
||||
}
|
||||
|
||||
enum InteractionState: Equatable {
|
||||
case idle
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
private enum SelectionGranularity {
|
||||
case character
|
||||
case word
|
||||
}
|
||||
|
||||
private(set) var isSelecting = false
|
||||
|
||||
private var selectionStartIndex: Int = NSNotFound
|
||||
|
||||
private var selectionEndIndex: Int = NSNotFound
|
||||
|
||||
private var activeGranularity: SelectionGranularity = .character
|
||||
|
||||
private var selectionAnchorIndex: Int = NSNotFound
|
||||
|
||||
private(set) var interactionState: InteractionState = .idle {
|
||||
didSet {
|
||||
guard interactionState != oldValue else { return }
|
||||
interactionStateDidChange?(interactionState)
|
||||
}
|
||||
}
|
||||
|
||||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||||
|
||||
var interactionStateDidChange: ((InteractionState) -> Void)?
|
||||
|
||||
var pageProvider: (() -> RDEPUBTextPage?)?
|
||||
|
||||
var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)?
|
||||
|
||||
var chapterFragmentOffsetsProvider: (() -> [String: Int])?
|
||||
|
||||
var hasActiveSelection: Bool {
|
||||
selectedAbsoluteRange != nil
|
||||
}
|
||||
|
||||
var selectedAbsoluteRange: NSRange? {
|
||||
guard selectionStartIndex != NSNotFound,
|
||||
selectionEndIndex != NSNotFound else {
|
||||
return nil
|
||||
}
|
||||
let lower = min(selectionStartIndex, selectionEndIndex)
|
||||
let upper = max(selectionStartIndex, selectionEndIndex)
|
||||
return NSRange(location: lower, length: max(upper - lower + 1, 1))
|
||||
}
|
||||
|
||||
func handleLongPress(
|
||||
_ gesture: UILongPressGestureRecognizer,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let renderView else { return }
|
||||
let point = gesture.location(in: renderView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
setInteractionState(.selecting)
|
||||
beginSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .changed:
|
||||
setInteractionState(.selecting)
|
||||
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .ended:
|
||||
isSelecting = false
|
||||
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
|
||||
case .cancelled, .failed:
|
||||
clearSelection(renderView: renderView)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handlePan(
|
||||
_ gesture: UIPanGestureRecognizer,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard isSelecting, let renderView else { return }
|
||||
let point = gesture.location(in: renderView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began, .changed:
|
||||
setInteractionState(.selecting)
|
||||
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .ended, .cancelled, .failed:
|
||||
isSelecting = false
|
||||
setInteractionState(hasActiveSelection ? .selectionActive : .idle)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelection(
|
||||
byAdjusting handle: BoundaryHandle,
|
||||
at point: CGPoint,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let renderView,
|
||||
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView),
|
||||
selectedAbsoluteRange != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
setInteractionState(.adjustingHandle)
|
||||
|
||||
switch handle {
|
||||
case .start:
|
||||
selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start)
|
||||
if selectionEndIndex != NSNotFound {
|
||||
selectionStartIndex = min(selectionStartIndex, selectionEndIndex)
|
||||
}
|
||||
case .end:
|
||||
selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end)
|
||||
if selectionStartIndex != NSNotFound {
|
||||
selectionEndIndex = max(selectionEndIndex, selectionStartIndex)
|
||||
}
|
||||
}
|
||||
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
|
||||
guard let absoluteRange = selectedAbsoluteRange else { return nil }
|
||||
return interactionController.menuAnchorRect(for: absoluteRange)
|
||||
}
|
||||
|
||||
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
|
||||
isSelecting = false
|
||||
selectionAnchorIndex = NSNotFound
|
||||
activeGranularity = .character
|
||||
selectionStartIndex = NSNotFound
|
||||
selectionEndIndex = NSNotFound
|
||||
setInteractionState(.idle)
|
||||
renderView?.selectionRects = []
|
||||
onSelectionChanged?(nil)
|
||||
}
|
||||
|
||||
private func beginSelection(
|
||||
at point: CGPoint,
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
|
||||
if !isSelecting {
|
||||
clearSelection(renderView: renderView)
|
||||
}
|
||||
return
|
||||
}
|
||||
selectionAnchorIndex = index
|
||||
activeGranularity = .word
|
||||
isSelecting = true
|
||||
applySelection(
|
||||
range: selectionRange(for: index, granularity: .word),
|
||||
renderView: renderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
}
|
||||
|
||||
private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? {
|
||||
guard let page = pageProvider?() else { return nil }
|
||||
|
||||
switch granularity {
|
||||
case .character:
|
||||
return NSRange(location: focusIndex, length: 1)
|
||||
case .word:
|
||||
if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) {
|
||||
return wordRange
|
||||
}
|
||||
return NSRange(location: focusIndex, length: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int {
|
||||
guard let page = pageProvider?() else { return index }
|
||||
let text = page.chapterContent.string as NSString
|
||||
guard let wordRange = wordRange(containing: index, in: text) else {
|
||||
return index
|
||||
}
|
||||
|
||||
switch handle {
|
||||
case .start:
|
||||
return wordRange.location
|
||||
case .end:
|
||||
return max(wordRange.location + wordRange.length - 1, wordRange.location)
|
||||
}
|
||||
}
|
||||
|
||||
private func wordRange(containing index: Int, in text: NSString) -> NSRange? {
|
||||
guard text.length > 0 else { return nil }
|
||||
let safeIndex = min(max(index, 0), max(text.length - 1, 0))
|
||||
if let scalar = UnicodeScalar(text.character(at: safeIndex)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
return nearestWordRange(to: safeIndex, in: text)
|
||||
?? text.rangeOfComposedCharacterSequence(at: safeIndex)
|
||||
}
|
||||
let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex)
|
||||
let probeRange = NSRange(location: safeIndex, length: 1)
|
||||
var matchedWordRange: NSRange?
|
||||
|
||||
text.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: text.length),
|
||||
options: [.byWords, .substringNotRequired]
|
||||
) { _, substringRange, _, stop in
|
||||
guard substringRange.length > 0 else { return }
|
||||
if NSIntersectionRange(substringRange, probeRange).length > 0
|
||||
|| NSLocationInRange(characterRange.location, substringRange) {
|
||||
matchedWordRange = substringRange
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
|
||||
if let matchedWordRange {
|
||||
let trimmedRange = trimmed(range: matchedWordRange, in: text)
|
||||
if trimmedRange.length > 0 {
|
||||
return trimmedRange
|
||||
}
|
||||
}
|
||||
|
||||
return characterRange
|
||||
}
|
||||
|
||||
private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? {
|
||||
var nearestRange: NSRange?
|
||||
var nearestDistance = Int.max
|
||||
|
||||
text.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: text.length),
|
||||
options: [.byWords, .substringNotRequired]
|
||||
) { _, substringRange, _, _ in
|
||||
guard substringRange.length > 0 else { return }
|
||||
let trimmedRange = self.trimmed(range: substringRange, in: text)
|
||||
guard trimmedRange.length > 0 else { return }
|
||||
|
||||
let distance: Int
|
||||
if index < trimmedRange.location {
|
||||
distance = trimmedRange.location - index
|
||||
} else if index >= trimmedRange.location + trimmedRange.length {
|
||||
distance = index - (trimmedRange.location + trimmedRange.length - 1)
|
||||
} else {
|
||||
distance = 0
|
||||
}
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestRange = trimmedRange
|
||||
}
|
||||
}
|
||||
|
||||
return nearestRange
|
||||
}
|
||||
|
||||
private func trimmed(range: NSRange, in text: NSString) -> NSRange {
|
||||
guard range.length > 0 else { return range }
|
||||
|
||||
var lowerBound = range.location
|
||||
var upperBound = range.location + range.length
|
||||
|
||||
while lowerBound < upperBound,
|
||||
let scalar = UnicodeScalar(text.character(at: lowerBound)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
lowerBound += 1
|
||||
}
|
||||
|
||||
while upperBound > lowerBound,
|
||||
let scalar = UnicodeScalar(text.character(at: upperBound - 1)),
|
||||
CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||||
upperBound -= 1
|
||||
}
|
||||
|
||||
return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0))
|
||||
}
|
||||
|
||||
private func applySelection(
|
||||
range: NSRange?,
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let range, range.location != NSNotFound, range.length > 0 else {
|
||||
clearSelection(renderView: renderView)
|
||||
return
|
||||
}
|
||||
selectionStartIndex = range.location
|
||||
selectionEndIndex = range.location + range.length - 1
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
private func updateSelection(
|
||||
at point: CGPoint,
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard selectionAnchorIndex != NSNotFound,
|
||||
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
|
||||
return
|
||||
}
|
||||
|
||||
let clampedIndex: Int
|
||||
switch activeGranularity {
|
||||
case .character:
|
||||
clampedIndex = index
|
||||
case .word:
|
||||
clampedIndex = snappedBoundaryIndex(
|
||||
for: index,
|
||||
handle: index >= selectionAnchorIndex ? .end : .start
|
||||
)
|
||||
}
|
||||
|
||||
selectionStartIndex = selectionAnchorIndex
|
||||
selectionEndIndex = clampedIndex
|
||||
applySelection(renderView: renderView, interactionController: interactionController)
|
||||
}
|
||||
|
||||
private func applySelection(
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let absoluteRange = selectedAbsoluteRange,
|
||||
let page = pageProvider?() else {
|
||||
clearSelection(renderView: renderView)
|
||||
return
|
||||
}
|
||||
|
||||
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
|
||||
setInteractionState(isSelecting ? .selecting : .selectionActive)
|
||||
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
|
||||
}
|
||||
|
||||
private func setInteractionState(_ state: InteractionState) {
|
||||
interactionState = state
|
||||
}
|
||||
|
||||
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||
guard absoluteRange.location != NSNotFound,
|
||||
absoluteRange.length > 0,
|
||||
NSMaxRange(absoluteRange) <= page.chapterContent.length else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
|
||||
guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let globalStart = absoluteRange.location
|
||||
let globalEnd = absoluteRange.location + absoluteRange.length
|
||||
let chapterData = makeChapterData(for: page)
|
||||
let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
|
||||
return RDEPUBSelection(
|
||||
location: location,
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData {
|
||||
let textChapter = RDEPUBTextChapter(
|
||||
chapterIndex: page.chapterIndex,
|
||||
spineIndex: page.spineIndex,
|
||||
href: page.href,
|
||||
title: page.chapterTitle,
|
||||
attributedContent: page.chapterContent,
|
||||
fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:],
|
||||
cfiMap: chapterCFIMapProvider?(),
|
||||
pageBreakReasons: [],
|
||||
pages: [page]
|
||||
)
|
||||
return RDEPUBChapterData(
|
||||
chapter: textChapter,
|
||||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user