From f48a5c1bf572e5731cc18ea300c7ef7611a7733d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 11 Oct 2026 14:07:21 -0700 Subject: [PATCH] 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 --- apps/ios/Snowbound/CanvasView.swift | 79 ++++++----------------------- 1 file changed, 15 insertions(+), 64 deletions(-) diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 46c6708ed55f07b0adc4b55a2378dd44ca5b3d56..0bfa59a16d9903e720f828e040afb78f8b462b2f 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -63,7 +63,7 @@ private final class CanvasUndoManager: UndoManager { /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text /// interaction, loupe and edit menu work on it. final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, - UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate, + UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate, UIPencilInteractionDelegate, UIScribbleInteractionDelegate { /// The zoom a page opens at once the reader has pinched one. @@ -99,18 +99,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI }() /// Moves or widens the focused outline from its grips. private lazy var grip = UIPanGestureRecognizer(target: self, action: #selector(gripped)) - /// Where the touch the grip and handle drags look at came down. + /// Where the outline grip drag started. private var touchStart = CGPoint.zero - /// Drags a selection handle, which the text interaction leaves to the view drawing it. - private lazy var handleDrag = UIPanGestureRecognizer(target: self, action: #selector(handleDragged)) - /// The selection end a handle drag keeps, and the loupe following the other. - private var handleAnchor = 0 - private var loupe: UITextLoupeSession? private lazy var editMenu = UIEditMenuInteraction(delegate: self) + /// Owns text gestures and their caret, highlight, handles and loupe together. private let interaction = UITextInteraction(for: .editable) - /// Draws the caret, selection highlight and handles, which the text interaction's - /// gestures move; the canvas paints neither. - private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) private let history = CanvasUndoManager() private let spaceHint = Hint() /// The Pencil's strokes, and a finger's while `fingerInks`. @@ -179,13 +172,13 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI layer.isOpaque = true // Frames reach the screen with the transaction that moves UIKit's caret and handles. layer.presentsWithTransaction = true - for recognizer in [tap, doubleTap, grip, handleDrag, ink] { + for recognizer in [tap, doubleTap, grip, ink] { recognizer.delegate = self addGestureRecognizer(recognizer) } // The Pencil draws; fingers, a trackpad and a mouse scroll and select. let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) } - let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer] + let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, panGestureRecognizer, pinchGestureRecognizer] for recognizer in recognizers { recognizer?.allowedTouchTypes = hands } @@ -197,12 +190,12 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI picker.onClose = { [weak self] in self?.showPicker(false) } tap.require(toFail: doubleTap) panGestureRecognizer.require(toFail: grip) - panGestureRecognizer.require(toFail: handleDrag) interaction.textInput = self interaction.delegate = self addInteraction(interaction) - addInteraction(display) - display.isActivated = false + for recognizer in interaction.gesturesForFailureRequirements { + panGestureRecognizer.require(toFail: recognizer) + } addInteraction(editMenu) history.canvas = self NotificationCenter.default.addObserver( @@ -444,7 +437,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } transform() // Scrolling carries the caret and handles along; zooming moves them within the page. - display.setNeedsSelectionUpdate() + inputDelegate?.selectionDidChange(self) } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { @@ -507,8 +500,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) } - if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) } - return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer) + if recognizer === grip { touchStart = touch.location(in: self) } + return !fingerInks || ![tap, doubleTap, grip].contains(recognizer) } override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { @@ -518,7 +511,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI && !onHandle(recognizer.location(in: self)) case doubleTap: target(recognizer.location(in: self)) == .page case grip: !readOnly && target(touchStart) == .grip - case handleDrag: onHandle(touchStart) default: super.gestureRecognizerShouldBegin(recognizer) } } @@ -582,61 +574,23 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } } - @objc private func handleDragged(_ recognizer: UIPanGestureRecognizer) { - guard let range = selectedTextRange else { return } - // The finger sits below the knob; aim at the text line above it. - let point = recognizer.location(in: self) - let aim = CGPoint(x: point.x, y: point.y - 16) - switch recognizer.state { - case .began: - let start = caretRect(for: range.start) - let end = caretRect(for: range.end) - let nearStart = hypot(start.midX - touchStart.x, start.midY - touchStart.y) - < hypot(end.midX - touchStart.x, end.midY - touchStart.y) - handleAnchor = nearStart ? range.upper : range.lower - let handle = display.handleViews.min { - hypot($0.center.x - touchStart.x, $0.center.y - touchStart.y) - < hypot($1.center.x - touchStart.x, $1.center.y - touchStart.y) - } - loupe = UITextLoupeSession.begin(at: aim, fromSelectionWidgetView: handle, in: self) - editMenu.dismissMenu() - fallthrough - case .changed: - guard let position = closestPosition(to: aim) else { return } - // A handle stops one character short of the other. - let step = position.offset < handleAnchor || (position.offset == handleAnchor && handleAnchor > 0) ? -1 : 1 - let end = position.offset == handleAnchor ? handleAnchor + step : position.offset - let moved = Range(handleAnchor, min(max(end, 0), endOfDocument.offset)) - edit(external: true) { choose(moved) } - let caret = caretRect(for: position) - loupe?.move(to: CGPoint(x: caret.midX, y: caret.midY), withCaretRect: caret, trackingCaret: false) - default: - loupe?.invalidate() - loupe = nil - let end = caretRect(for: range.end) - editMenu.presentEditMenu(with: UIEditMenuConfiguration( - identifier: nil, sourcePoint: CGPoint(x: end.midX, y: caretRect(for: range.start).minY))) - } - } - @objc private func gripped(_ recognizer: UIPanGestureRecognizer) { guard let handle else { return } let point = visible(recognizer.location(in: self)) switch recognizer.state { case .began: + inputDelegate?.selectionWillChange(self) fingerTool(at: touchStart) let start = visible(touchStart) _ = sb_view_press(handle, Float(start.x), Float(start.y)) _ = sb_view_drag(handle, Float(point.x), Float(point.y)) - // The caret and handles stay behind until the outline lands. - display.isActivated = false + inputDelegate?.selectionDidChange(self) editMenu.dismissMenu() dirty = true case .changed: _ = sb_view_drag(handle, Float(point.x), Float(point.y)) dirty = true default: - display.isActivated = isFirstResponder edit(external: true) { sb_view_release(handle) } spaceHint.hide() } @@ -825,7 +779,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI sync() revealCaret() } - display.setNeedsSelectionUpdate() changedSelection() return changed } @@ -858,7 +811,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } guard super.becomeFirstResponder() else { return false } if let handle { sb_view_focus(handle, true) } - display.isActivated = true dirty = true changedSelection() return true @@ -867,7 +819,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func resignFirstResponder() -> Bool { guard super.resignFirstResponder() else { return false } if let handle { sb_view_focus(handle, false) } - display.isActivated = false dirty = true onChange?() return true @@ -1305,7 +1256,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI var selectedTextRange: UITextRange? { get { - guard let handle else { return nil } + // Outline previews move before their text geometry is committed. + guard let handle, grip.state != .began, grip.state != .changed else { return nil } var range: [UInt32] = [0, 0] sb_selection(handle, &range) return Range(Int(range[0]), Int(range[1])) @@ -1313,7 +1265,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI set { guard let newValue else { return } _ = choose(newValue) - display.setNeedsSelectionUpdate() dirty = true changedSelection() } -- 2.54.0