From 3835d9f9c46a832d480b388cd4ad32120cfcde4d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 17 Jun 2026 15:43:26 -0400 Subject: [PATCH] fix(RadialKnob): real pointer capture via setPointerCapture interop Switch initiator to @onpointerdown; capture the pointer on the knob element through a new knob.ts helper so pointermove/up/cancel reach the knob even when the cursor leaves the window. Accurate comment; IAsyncDisposable cleanup. --- .../Components/RadialKnob.razor | 69 +++++++++++++++---- DeepDrftShared.Client/Interop/knob/knob.ts | 20 ++++++ 2 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 DeepDrftShared.Client/Interop/knob/knob.ts diff --git a/DeepDrftShared.Client/Components/RadialKnob.razor b/DeepDrftShared.Client/Components/RadialKnob.razor index 3beb2ed..78b170b 100644 --- a/DeepDrftShared.Client/Components/RadialKnob.razor +++ b/DeepDrftShared.Client/Components/RadialKnob.razor @@ -1,21 +1,29 @@ @using System.Globalization +@inject IJSRuntime JS +@implements IAsyncDisposable - + Provides the cursor hint and blocks stray clicks on underlying content during a drag. + Event delivery is NOT via this div — setPointerCapture on the knob element routes + pointermove/pointerup/pointercancel directly to the knob regardless of where the cursor is, + even outside the browser window. This overlay is a belt-and-suspenders UX guard only. --> @if (_isDragging) { -
+
} +
+ @ref="_knobRef" + @onpointerdown="@OnPointerDown" + @onpointermove="@OnPointerMove" + @onpointerup="@OnPointerUp" + @onpointercancel="@OnPointerCancel"> @@ -84,6 +92,9 @@ [Parameter] public MudBlazor.Color Color { get; set; } = MudBlazor.Color.Primary; [Parameter] public bool HoldValue { get; set; } = false; + private ElementReference _knobRef; + private IJSObjectReference? _knobModule; + private bool _isDragging = false; private double _lastMouseY = 0; private double _dragValue = 0; @@ -156,17 +167,27 @@ return y.ToString("F1", CultureInfo.InvariantCulture); } - private async Task OnMouseDown(MouseEventArgs e) + private async Task OnPointerDown(PointerEventArgs e) { _isDragging = true; _lastMouseY = e.ClientY; _dragValue = Value; // Initialize drag value with current value - // The full-viewport capture div renders; pointer events on it will handle the rest. + // Load the JS module on first drag (lazy — avoids import cost until the user interacts). + _knobModule ??= await JS.InvokeAsync( + "import", "./_content/DeepDrftShared.Client/js/knob/knob.js"); + + // Capture the pointer on the knob element so pointermove/pointerup are delivered + // even when the cursor leaves the browser window mid-drag. + await _knobModule.InvokeVoidAsync("capturePointer", _knobRef, e.PointerId); + + // The full-viewport capture div (rendered when _isDragging) is a belt-and-suspenders + // guard that blocks stray clicks on underlying content while dragging. StateHasChanged(); } - private async Task OnGlobalPointerMove(PointerEventArgs e) + // Delivered to the knob element because setPointerCapture routes the captured pointer here. + private async Task OnPointerMove(PointerEventArgs e) { if (_isDragging) { @@ -174,7 +195,8 @@ } } - private async Task OnGlobalPointerUp(PointerEventArgs e) + // pointerup implicitly releases pointer capture — no explicit releasePointerCapture needed. + private async Task OnPointerUp(PointerEventArgs e) { if (_isDragging && HoldValue) { @@ -190,12 +212,31 @@ } // Pointer capture cancelled by OS (e.g. Alt+Tab, system gesture) — end drag cleanly. - private async Task OnGlobalPointerCancel(PointerEventArgs e) + // Delivered to the knob element (the capturing element). Release capture explicitly since + // the implicit release on pointerup does not apply to pointercancel. + private async Task OnPointerCancel(PointerEventArgs e) { + if (_knobModule != null) + { + try { await _knobModule.InvokeVoidAsync("releasePointer", _knobRef, e.PointerId); } + catch (JSException) { /* element may already be gone */ } + } + _isDragging = false; StateHasChanged(); } + public async ValueTask DisposeAsync() + { + if (_knobModule != null) + { + try { await _knobModule.DisposeAsync(); } + catch (JSDisconnectedException) { /* circuit torn down */ } + } + + GC.SuppressFinalize(this); + } + private async Task UpdateValueFromPointer(PointerEventArgs e) { // Calculate vertical delta from last pointer position diff --git a/DeepDrftShared.Client/Interop/knob/knob.ts b/DeepDrftShared.Client/Interop/knob/knob.ts new file mode 100644 index 0000000..91e72fb --- /dev/null +++ b/DeepDrftShared.Client/Interop/knob/knob.ts @@ -0,0 +1,20 @@ +/** + * knob - pointer capture helpers for RadialKnob. + * + * setPointerCapture / releasePointerCapture are not exposed via Blazor's + * ElementReference, so the component delegates here via JS interop. + * Both functions are no-ops when the element reference is stale (e.g. the + * component was disposed between the JS call and the microtask). + */ + +/** Capture the pointer on the given element so pointermove/pointerup are + * delivered even when the cursor leaves the browser window. */ +export function capturePointer(el: Element, pointerId: number): void { + (el as HTMLElement).setPointerCapture(pointerId); +} + +/** Release a previously captured pointer. Called on pointercancel. + * pointerup releases capture implicitly, but we call this on cancel too. */ +export function releasePointer(el: Element, pointerId: number): void { + (el as HTMLElement).releasePointerCapture(pointerId); +}