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() }