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,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