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 {...@@ -63,7 +63,7 @@ private final class CanvasUndoManager: UndoManager {
63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
64/// interaction, loupe and edit menu work on it.64/// interaction, loupe and edit menu work on it.
65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,66 UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
68{68{
69 /// The zoom a page opens at once the reader has pinched one.69 /// The zoom a page opens at once the reader has pinched one.
...@@ -99,18 +99,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -99,18 +99,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
99 }()99 }()
100 /// Moves or widens the focused outline from its grips.100 /// Moves or widens the focused outline from its grips.
101 private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped))101 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.
103 private var touchStart = CGPoint.zero103 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?
109 private lazy var editMenu = UIEditMenuInteraction(delegate: self)104 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
105 /// Owns text gestures and their caret, highlight, handles and loupe together.
110 private let interaction = UITextInteraction(for: .editable)106 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)
114 private let history = CanvasUndoManager()107 private let history = CanvasUndoManager()
115 private let spaceHint = Hint()108 private let spaceHint = Hint()
116 /// The Pencil's strokes, and a finger's while `fingerInks`.109 /// The Pencil's strokes, and a finger's while `fingerInks`.
...@@ -179,13 +172,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -179,13 +172,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
179 layer.isOpaque = true172 layer.isOpaque = true
180 // Frames reach the screen with the transaction that moves UIKit's caret and handles.173 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
181 layer.presentsWithTransaction = true174 layer.presentsWithTransaction = true
182 for recognizer in [tap, doubleTap, grip, handleDrag, ink] {175 for recognizer in [tap, doubleTap, grip, ink] {
183 recognizer.delegate = self176 recognizer.delegate = self
184 addGestureRecognizer(recognizer)177 addGestureRecognizer(recognizer)
185 }178 }
186 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.179 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.
187 let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) }180 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]
189 for recognizer in recognizers {182 for recognizer in recognizers {
190 recognizer?.allowedTouchTypes = hands183 recognizer?.allowedTouchTypes = hands
191 }184 }
...@@ -197,12 +190,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -197,12 +190,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
197 picker.onClose = { [weak self] in self?.showPicker(false) }190 picker.onClose = { [weak self] in self?.showPicker(false) }
198 tap.require(toFail: doubleTap)191 tap.require(toFail: doubleTap)
199 panGestureRecognizer.require(toFail: grip)192 panGestureRecognizer.require(toFail: grip)
200 panGestureRecognizer.require(toFail: handleDrag)
201 interaction.textInput = self193 interaction.textInput = self
202 interaction.delegate = self194 interaction.delegate = self
203 addInteraction(interaction)195 addInteraction(interaction)
204 addInteraction(display)196 for recognizer in interaction.gesturesForFailureRequirements {
205 display.isActivated = false197 panGestureRecognizer.require(toFail: recognizer)
198 }
206 addInteraction(editMenu)199 addInteraction(editMenu)
207 history.canvas = self200 history.canvas = self
208 NotificationCenter.default.addObserver(201 NotificationCenter.default.addObserver(
...@@ -444,7 +437,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -444,7 +437,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
444 }437 }
445 transform()438 transform()
446 // Scrolling carries the caret and handles along; zooming moves them within the page.439 // Scrolling carries the caret and handles along; zooming moves them within the page.
447 display.setNeedsSelectionUpdate()440 inputDelegate?.selectionDidChange(self)
448 }441 }
449442
450 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {443 func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
...@@ -507,8 +500,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -507,8 +500,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
507500
508 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {501 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
509 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }502 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
510 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }503 if recognizer === grip { touchStart = touch.location(in: self) }
511 return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer)504 return !fingerInks || ![tap, doubleTap, grip].contains(recognizer)
512 }505 }
513506
514 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {507 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
...@@ -518,7 +511,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -518,7 +511,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
518 && !onHandle(recognizer.location(in: self))511 && !onHandle(recognizer.location(in: self))
519 case doubleTap: target(recognizer.location(in: self)) == .page512 case doubleTap: target(recognizer.location(in: self)) == .page
520 case grip: !readOnly && target(touchStart) == .grip513 case grip: !readOnly && target(touchStart) == .grip
521 case handleDrag: onHandle(touchStart)
522 default: super.gestureRecognizerShouldBegin(recognizer)514 default: super.gestureRecognizerShouldBegin(recognizer)
523 }515 }
524 }516 }
...@@ -582,61 +574,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -582,61 +574,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
582 }574 }
583 }575 }
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
622 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {577 @objc private func gripped(_ recognizer: UIPanGestureRecognizer) {
623 guard let handle else { return }578 guard let handle else { return }
624 let point = visible(recognizer.location(in: self))579 let point = visible(recognizer.location(in: self))
625 switch recognizer.state {580 switch recognizer.state {
626 case .began:581 case .began:
582 inputDelegate?.selectionWillChange(self)
627 fingerTool(at: touchStart)583 fingerTool(at: touchStart)
628 let start = visible(touchStart)584 let start = visible(touchStart)
629 _ = sb_view_press(handle, Float(start.x), Float(start.y))585 _ = sb_view_press(handle, Float(start.x), Float(start.y))
630 _ = sb_view_drag(handle, Float(point.x), Float(point.y))586 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
631 // The caret and handles stay behind until the outline lands.587 inputDelegate?.selectionDidChange(self)
632 display.isActivated = false
633 editMenu.dismissMenu()588 editMenu.dismissMenu()
634 dirty = true589 dirty = true
635 case .changed:590 case .changed:
636 _ = sb_view_drag(handle, Float(point.x), Float(point.y))591 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
637 dirty = true592 dirty = true
638 default:593 default:
639 display.isActivated = isFirstResponder
640 edit(external: true) { sb_view_release(handle) }594 edit(external: true) { sb_view_release(handle) }
641 spaceHint.hide()595 spaceHint.hide()
642 }596 }
...@@ -825,7 +779,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -825,7 +779,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
825 sync()779 sync()
826 revealCaret()780 revealCaret()
827 }781 }
828 display.setNeedsSelectionUpdate()
829 changedSelection()782 changedSelection()
830 return changed783 return changed
831 }784 }
...@@ -858,7 +811,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -858,7 +811,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
858 }811 }
859 guard super.becomeFirstResponder() else { return false }812 guard super.becomeFirstResponder() else { return false }
860 if let handle { sb_view_focus(handle, true) }813 if let handle { sb_view_focus(handle, true) }
861 display.isActivated = true
862 dirty = true814 dirty = true
863 changedSelection()815 changedSelection()
864 return true816 return true
...@@ -867,7 +819,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -867,7 +819,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
867 override func resignFirstResponder() -> Bool {819 override func resignFirstResponder() -> Bool {
868 guard super.resignFirstResponder() else { return false }820 guard super.resignFirstResponder() else { return false }
869 if let handle { sb_view_focus(handle, false) }821 if let handle { sb_view_focus(handle, false) }
870 display.isActivated = false
871 dirty = true822 dirty = true
872 onChange?()823 onChange?()
873 return true824 return true
...@@ -1305,7 +1256,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1305,7 +1256,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
13051256
1306 var selectedTextRange: UITextRange? {1257 var selectedTextRange: UITextRange? {
1307 get {1258 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 }
1309 var range: [UInt32] = [0, 0]1261 var range: [UInt32] = [0, 0]
1310 sb_selection(handle, &range)1262 sb_selection(handle, &range)
1311 return Range(Int(range[0]), Int(range[1]))1263 return Range(Int(range[0]), Int(range[1]))
...@@ -1313,7 +1265,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -1313,7 +1265,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
1313 set {1265 set {
1314 guard let newValue else { return }1266 guard let newValue else { return }
1315 _ = choose(newValue)1267 _ = choose(newValue)
1316 display.setNeedsSelectionUpdate()
1317 dirty = true1268 dirty = true
1318 changedSelection()1269 changedSelection()
1319 }1270 }