diff --git a/src/Capacitor.App/Views/ChatTabView.axaml.cs b/src/Capacitor.App/Views/ChatTabView.axaml.cs index 4f7dbfd7f4..274a4454a6 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml.cs +++ b/src/Capacitor.App/Views/ChatTabView.axaml.cs @@ -29,6 +29,10 @@ public partial class ChatTabView : UserControl { bool _readerGesture; int _listInputVersion; bool _thumbDragging; + bool _dragSelecting; + bool _edgeScrollQueued; + Point _dragInScroll; + MarkdownView? _dragView; double? _thumbFraction; double _smoothRemaining; bool _smoothFrameQueued; @@ -49,7 +53,9 @@ public ChatTabView() { // Press and release both: a click's command runs on the release, in a later dispatcher turn // than the press, by which time a flag armed on the press alone has been released. ChatItems.AddHandler(PointerPressedEvent, OnReaderGesture, RoutingStrategies.Tunnel, handledEventsToo: true); + ChatItems.AddHandler(PointerMovedEvent, OnSelectionDrag, RoutingStrategies.Bubble, handledEventsToo: true); ChatItems.AddHandler(PointerReleasedEvent, OnReaderGesture, RoutingStrategies.Tunnel, handledEventsToo: true); + ChatItems.AddHandler(PointerReleasedEvent, OnSelectionDragEnded, RoutingStrategies.Tunnel, handledEventsToo: true); ChatItems.AddHandler(PointerWheelChangedEvent, OnReaderGesture, RoutingStrategies.Tunnel, handledEventsToo: true); ChatItems.AddHandler(PointerWheelChangedEvent, OnChatWheelChanged, RoutingStrategies.Tunnel); ChatItems.AddHandler(KeyDownEvent, OnReaderGesture, RoutingStrategies.Tunnel, handledEventsToo: true); @@ -95,6 +101,8 @@ protected override void OnDataContextChanged(EventArgs e) { _thumbDragging = false; _thumbFraction = null; _readerGesture = false; + _dragSelecting = false; + _dragView = null; _followTail = true; base.OnDataContextChanged(e); } @@ -104,6 +112,8 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e CancelSmoothScroll(); _thumbDragging = false; _thumbFraction = null; + _dragSelecting = false; + _dragView = null; _attachments?.Dispose(); _attachments = null; base.OnDetachedFromVisualTree(e); @@ -385,6 +395,62 @@ void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { if (_followTail && !atBottom) scroll.ScrollToEnd(); } + void OnSelectionDrag(object? sender, PointerEventArgs e) { + EnsureChatScroll(); + if (_scroll is null) return; + if (!e.GetCurrentPoint(ChatItems).Properties.IsLeftButtonPressed || OriginatesFromTextBox(e)) { + _dragSelecting = false; + return; + } + if (e.Source is not Visual visual) return; + var view = visual.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + if (!_dragSelecting) { + if (view is not { IsSelecting: true }) return; + _dragView = view; + _dragSelecting = true; + } else if (view is { IsSelecting: true }) { + _dragView = view; + } + if (_dragView is { HasSelection: true }) _followTail = false; + _dragInScroll = e.GetPosition(_scroll); + QueueEdgeScroll(); + } + + void OnSelectionDragEnded(object? sender, RoutedEventArgs e) => _dragSelecting = false; + + void QueueEdgeScroll() { + if (_edgeScrollQueued || !_dragSelecting || _scroll is null) return; + if (TopLevel.GetTopLevel(this) is not { } top) return; + _edgeScrollQueued = true; + top.RequestAnimationFrame(_ => { + _edgeScrollQueued = false; + if (_dragSelecting) StepEdgeScroll(); + }); + } + + void StepEdgeScroll() { + if (_scroll is null || !_dragSelecting) return; + const double edge = 28; + const double step = 18; + var y = _dragInScroll.Y; + var height = _scroll.Viewport.Height; + var delta = y < edge ? -step : y > height - edge ? step : 0.0; + if (Math.Abs(delta) < 0.01) return; + var max = Math.Max(0, _scroll.Extent.Height - _scroll.Viewport.Height); + var next = Math.Clamp(_scroll.Offset.Y + delta, 0, max); + if (Math.Abs(next - _scroll.Offset.Y) > 0.5) { + var wasGesture = _readerGesture; + _readerGesture = true; + _scroll.Offset = new Vector(_scroll.Offset.X, next); + if (!wasGesture) Dispatcher.UIThread.Post(() => _readerGesture = false, DispatcherPriority.Background); + } + if (_dragView?.TranslatePoint(default, _scroll) is { } origin) { + _dragView.ExtendSelection(new Point(_dragInScroll.X - origin.X, _dragInScroll.Y - origin.Y)); + if (_dragView.HasSelection) _followTail = false; + } + QueueEdgeScroll(); + } + static bool OriginatesFromTextBox(RoutedEventArgs e) => e.Source is Visual visual && (visual is TextBox || visual.GetVisualAncestors().OfType().Any()); diff --git a/src/Capacitor.App/Views/MarkViewSelection.cs b/src/Capacitor.App/Views/MarkViewSelection.cs index 6acae1fd92..2c4ecd1338 100644 --- a/src/Capacitor.App/Views/MarkViewSelection.cs +++ b/src/Capacitor.App/Views/MarkViewSelection.cs @@ -32,6 +32,20 @@ public static void Attach(Control host, MarkdownViewer viewer, Func copy) host.AddHandler(InputElement.KeyDownEvent, (_, e) => OnKeyDown(copy, e), RoutingStrategies.Tunnel); } + /// Continues a drag at a point in the viewer's coordinates. Used when the chat scrolls under a + /// pointer that is sitting on the viewport edge, so the selection grows without a new move. + public static void Extend(MarkdownViewer viewer, Point pointInViewer) { + if (LayerOf(viewer) is not Visual layer) return; + if (Anchor(layer) is null) return; + IsDragging(viewer) = true; + var point = viewer.TranslatePoint(pointInViewer, layer) ?? pointInViewer; + if (HitTestOffset(layer, point) is not null) MoveFocus(layer, point); + else if (Nearest(layer, point) is { } inside) MoveFocus(layer, inside); + } + + public static bool IsSelecting(MarkdownViewer viewer) => + IsDragging(viewer) && LayerOf(viewer) is { } layer && Anchor(layer) is not null; + public static bool HasSelection(MarkdownViewer viewer) => LayerOf(viewer) is { } layer && Anchor(layer) is { } anchor && Focus(layer) is { } focus && anchor != focus; diff --git a/src/Capacitor.App/Views/MarkdownView.cs b/src/Capacitor.App/Views/MarkdownView.cs index 057d5f9d4d..00db2e5311 100644 --- a/src/Capacitor.App/Views/MarkdownView.cs +++ b/src/Capacitor.App/Views/MarkdownView.cs @@ -62,6 +62,12 @@ public MarkdownView() { Content = _viewer; } + internal bool IsSelecting => Content is MarkdownViewer && MarkViewSelection.IsSelecting(_viewer); + + internal void ExtendSelection(Point pointInView) { + if (Content is MarkdownViewer) MarkViewSelection.Extend(_viewer, pointInView); + } + public bool HasSelection => Content is SelectableTextBlock plain ? !string.IsNullOrEmpty(plain.SelectedText) : MarkViewSelection.HasSelection(_viewer);