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.
This commit is contained in:
daniel-c-harvey
2026-06-17 15:43:26 -04:00
parent 8a329aadcf
commit 3835d9f9c4
2 changed files with 75 additions and 14 deletions
@@ -1,21 +1,29 @@
@using System.Globalization
@inject IJSRuntime JS
@implements IAsyncDisposable
<!-- Global pointer-capture container when dragging.
<!-- Drag-shield: full-viewport overlay while dragging.
position:fixed; inset:0; z-index:9999 so it sits above all overlays (the controls modal is z-index:1400).
Pointer events used instead of mouse events: @onpointermove / @onpointerup fire even if the
cursor moves outside the browser window when pointer capture is active (set on mousedown below).
@onpointercancel covers the OS-level cancel path (e.g. Alt+Tab on Windows). Drag always ends cleanly. -->
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)
{
<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999; cursor: ns-resize;"
@onpointermove="@OnGlobalPointerMove"
@onpointerup="@OnGlobalPointerUp"
@onpointercancel="@OnGlobalPointerCancel">
<div style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 9999; cursor: ns-resize;">
</div>
}
<!-- Knob element owns pointer capture during drag.
@onpointerdown initiates drag and calls setPointerCapture via knob.js.
@onpointermove / @onpointerup / @onpointercancel are delivered here (not to the overlay)
for the captured pointer, even when the cursor has left the browser window. -->
<div class="radial-knob" style="width: @(Size)px; height: @(Size)px; display: inline-block; position: relative;"
@onmousedown="@OnMouseDown">
@ref="_knobRef"
@onpointerdown="@OnPointerDown"
@onpointermove="@OnPointerMove"
@onpointerup="@OnPointerUp"
@onpointercancel="@OnPointerCancel">
<!-- SVG Knob -->
<svg width="@Size" height="@Size" viewBox="0 0 100 100">
@@ -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<IJSObjectReference>(
"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
@@ -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);
}