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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user