- 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
389 lines
14 KiB
Swift
389 lines
14 KiB
Swift
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])
|
|
)
|
|
}
|
|
}
|