authorclover caruso <git@paperclover.net> 2026-10-11 14:07:21-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 14:49:45-07:00
logf48a5c1bf572e5731cc18ea300c7ef7611a7733d
tree49396ba08484ed327dc9c16983a1cd67f6ce8e7e
parent07be1bb63a85dededae39e376dc14ec518026a01
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(ios): use one native text selection interaction

Let UITextInteraction own the caret, highlight, handles, and loupe. Remove the duplicate display interaction and manual handle drag path, and let native text gestures precede scrolling. Hide native selection during outline previews until their text geometry is committed. Fixes #127 Assisted-by: gpt-6.1-sol

1 files changed, 15 insertions(+), 64 deletions(-)

apps/ios/Snowbound/CanvasView.swift+15-64
......@@ -63,7 +63,7 @@ private final class CanvasUndoManager: UndoManager {
6363/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
6464/// interaction, loupe and edit menu work on it.
6565final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
66 UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
6767 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
6868{
6969 /// The zoom a page opens at once the reader has pinched one.
......@@ -99,18 +99,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
9999 }()
100100 /// Moves or widens the focused outline from its grips.
101101 private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped))
102 /// Where the touch the grip and handle drags look at came down.
102 /// Where the outline grip drag started.
103103 private var touchStart = CGPoint.zero
104 /// Drags a selection handle, which the text interaction leaves to the view drawing it.
105 private lazy var handleDrag = UIPanGestureRecognizer(target: self, action: #selector(handleDragged))
106 /// The selection end a handle drag keeps, and the loupe following the other.
107 private var handleAnchor = 0
108 private var loupe: UITextLoupeSession?
109104 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
105 /// Owns text gestures and their caret, highlight, handles and loupe together.
110106 private let interaction = UITextInteraction(for: .editable)
111 /// Draws the caret, selection highlight and handles, which the text interaction's
112 /// gestures move; the canvas paints neither.
113 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
114107 private let history = CanvasUndoManager()
115108 private let spaceHint = Hint()
116109 /// The Pencil's strokes, and a finger's while `fingerInks`.
......@@ -179,13 +172,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
179172 layer.isOpaque = true
180173 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
181174 layer.presentsWithTransaction = true
182 for recognizer in [tap, doubleTap, grip, handleDrag, ink] {
175 for recognizer in [tap, doubleTap, grip, ink] {
183176 recognizer.delegate = self
184177 addGestureRecognizer(recognizer)
185178 }
186179 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.
187180 let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) }
188 let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer]
181 let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, panGestureRecognizer, pinchGestureRecognizer]
189182 for recognizer in recognizers {
190183 recognizer?.allowedTouchTypes = hands
191184 }
......@@ -197,12 +190,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
197190 picker.onClose = { [weak self] in self?.showPicker(false) }
198191 tap.require(toFail: doubleTap)
199192 panGestureRecognizer.require(toFail: grip)
200 panGestureRecognizer.require(toFail: handleDrag)
201193 interaction.textInput = self
202194 interaction.delegate = self
203195 addInteraction(interaction)
204 addInteraction(display)
205 display.isActivated = false
196 for recognizer in interaction.gesturesForFailureRequirements {
197 panGestureRecognizer.require(toFail: recognizer)
198 }
206199 addInteraction(editMenu)
207200 history.canvas = self
208201 NotificationCenter.default.addObserver(
......@@ -444,7 +437,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
444437 }
445438 transform()
446439 // Scrolling carries the caret and handles along; zooming moves them within the page.
447 display.setNeedsSelectionUpdate()
440 inputDelegate?.selectionDidChange(self)
448441 }
449442
450443 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
......@@ -507,8 +500,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
507500
508501 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
509502 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
510 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }
511 return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer)
503 if recognizer === grip { touchStart = touch.location(in: self) }
504 return !fingerInks || ![tap, doubleTap, grip].contains(recognizer)
512505 }
513506
514507 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
......@@ -518,7 +511,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
518511 && !onHandle(recognizer.location(in: self))
519512 case doubleTap: target(recognizer.location(in: self)) == .page
520513 case grip: !readOnly && target(touchStart) == .grip
521 case handleDrag: onHandle(touchStart)
522514 default: super.gestureRecognizerShouldBegin(recognizer)
523515 }
524516 }
......@@ -582,61 +574,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
582574 }
583575 }
584576
585 @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) {
586 guard let range = selectedTextRange else { return }
587 // The finger sits below the knob; aim at the text line above it.
588 let point = recognizer.location(in: self)
589 let aim = CGPoint(x: point.x, y: point.y - 16)
590 switch recognizer.state {
591 case .began:
592 let start = caretRect(for: range.start)
593 let end = caretRect(for: range.end)
594 let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y)
595 < hypot(end.midX - touchStart.x, end.midY - touchStart.y)
596 handleAnchor = nearStart ? range.upper : range.lower
597 let handle = display.handleViews.min {
598 hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y)
599 < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y)
600 }
601 loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self)
602 editMenu.dismissMenu()
603 fallthrough
604 case .changed:
605 guard let position = closestPosition(to: aim) else { return }
606 // A handle stops one character short of the other.
607 let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1
608 let end = position.offset == handleAnchor ? handleAnchor + step : position.offset
609 let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset))
610 edit(external: true) { choose(moved) }
611 let caret = caretRect(for: position)
612 loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false)
613 default:
614 loupe?.invalidate()
615 loupe = nil
616 let end = caretRect(for: range.end)
617 editMenu.presentEditMenu(with: UIEditMenuConfiguration(
618 identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY)))
619 }
620 }
621
622577 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {
623578 guard let handle else { return }
624579 let point = visible(recognizer.location(in: self))
625580 switch recognizer.state {
626581 case .began:
582 inputDelegate?.selectionWillChange(self)
627583 fingerTool(at: touchStart)
628584 let start = visible(touchStart)
629585 _ = sb_view_press(handle, Float(start.x), Float(start.y))
630586 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
631 // The caret and handles stay behind until the outline lands.
632 display.isActivated = false
587 inputDelegate?.selectionDidChange(self)
633588 editMenu.dismissMenu()
634589 dirty = true
635590 case .changed:
636591 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
637592 dirty = true
638593 default:
639 display.isActivated = isFirstResponder
640594 edit(external: true) { sb_view_release(handle) }
641595 spaceHint.hide()
642596 }
......@@ -825,7 +779,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
825779 sync()
826780 revealCaret()
827781 }
828 display.setNeedsSelectionUpdate()
829782 changedSelection()
830783 return changed
831784 }
......@@ -858,7 +811,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
858811 }
859812 guard super.becomeFirstResponder() else { return false }
860813 if let handle { sb_view_focus(handle, true) }
861 display.isActivated = true
862814 dirty = true
863815 changedSelection()
864816 return true
......@@ -867,7 +819,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
867819 override func resignFirstResponder() -> Bool {
868820 guard super.resignFirstResponder() else { return false }
869821 if let handle { sb_view_focus(handle, false) }
870 display.isActivated = false
871822 dirty = true
872823 onChange?()
873824 return true
......@@ -1305,7 +1256,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
13051256
13061257 var selectedTextRange: UITextRange? {
13071258 get {
1308 guard let handle else { return nil }
1259 // Outline previews move before their text geometry is committed.
1260 guard let handle, grip.state != .began, grip.state != .changed else { return nil }
13091261 var range: [UInt32] = [0, 0]
13101262 sb_selection(handle, &range)
13111263 return Range(Int(range[0]), Int(range[1]))
......@@ -1313,7 +1265,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
13131265 set {
13141266 guard let newValue else { return }
13151267 _ = choose(newValue)
1316 display.setNeedsSelectionUpdate()
13171268 dirty = true
13181269 changedSelection()
13191270 }