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);
+}