3835d9f9c4
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.
279 lines
10 KiB
Plaintext
279 lines
10 KiB
Plaintext
@using System.Globalization
|
|
@inject IJSRuntime JS
|
|
@implements IAsyncDisposable
|
|
|
|
<!-- 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).
|
|
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;">
|
|
</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;"
|
|
@ref="_knobRef"
|
|
@onpointerdown="@OnPointerDown"
|
|
@onpointermove="@OnPointerMove"
|
|
@onpointerup="@OnPointerUp"
|
|
@onpointercancel="@OnPointerCancel">
|
|
|
|
<!-- SVG Knob -->
|
|
<svg width="@Size" height="@Size" viewBox="0 0 100 100">
|
|
<!-- Background track (270 degree arc) -->
|
|
<circle cx="50" cy="50" r="35" fill="none"
|
|
stroke="var(--mud-palette-surface-variant)"
|
|
stroke-width="6"
|
|
stroke-dasharray="@GetBackgroundStrokeDashArray()"
|
|
stroke-dashoffset="0"
|
|
transform="rotate(-225 50 50)" />
|
|
|
|
<!-- Value arc -->
|
|
<circle cx="50" cy="50" r="35" fill="none"
|
|
stroke="@GetKnobColor()"
|
|
stroke-width="6"
|
|
stroke-linecap="round"
|
|
stroke-dasharray="@GetStrokeDashArray()"
|
|
stroke-dashoffset="@GetStrokeDashOffset()"
|
|
transform="rotate(-225 50 50)" />
|
|
|
|
<!-- Center circle -->
|
|
<circle cx="50" cy="50" r="8" fill="var(--mud-palette-surface)" stroke="@GetKnobColor()" stroke-width="2" />
|
|
|
|
<!-- Pointer line -->
|
|
<line x1="50" y1="50"
|
|
x2="@GetPointerX()" y2="@GetPointerY()"
|
|
stroke="@GetKnobColor()"
|
|
stroke-width="3"
|
|
stroke-linecap="round" />
|
|
|
|
<!-- Value label inside SVG (moved down 8px from center) -->
|
|
<text x="50" y="100" text-anchor="middle"
|
|
font-size="22px" font-weight="bold"
|
|
fill="var(--mud-palette-text-primary)">
|
|
@GetDisplayLabel()
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<style>
|
|
.radial-knob {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
}
|
|
|
|
.radial-knob:hover svg circle:nth-child(2) {
|
|
stroke-width: 7;
|
|
}
|
|
|
|
.radial-knob:active svg circle:nth-child(2) {
|
|
stroke-width: 8;
|
|
}
|
|
</style>
|
|
|
|
@code {
|
|
[Parameter] public double Value { get; set; }
|
|
[Parameter] public EventCallback<double> ValueChanged { get; set; }
|
|
[Parameter] public double Min { get; set; } = 0;
|
|
[Parameter] public double Max { get; set; } = 100;
|
|
[Parameter] public double Step { get; set; } = 1;
|
|
[Parameter] public string Label { get; set; } = "";
|
|
[Parameter] public int Size { get; set; } = 50;
|
|
[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;
|
|
|
|
private double GetNormalizedValue()
|
|
{
|
|
// Use drag value during dragging if HoldValue is enabled, otherwise use current Value
|
|
double currentValue = (_isDragging && HoldValue) ? _dragValue : Value;
|
|
return Math.Max(0, Math.Min(1, (currentValue - Min) / (Max - Min)));
|
|
}
|
|
|
|
private string GetDisplayLabel()
|
|
{
|
|
// Show drag value during dragging if HoldValue is enabled, otherwise use provided Label
|
|
if (_isDragging && HoldValue)
|
|
{
|
|
return _dragValue.ToString("F0");
|
|
}
|
|
return Label;
|
|
}
|
|
|
|
private string GetKnobColor()
|
|
{
|
|
return Color switch
|
|
{
|
|
MudBlazor.Color.Primary => "var(--mud-palette-primary)",
|
|
MudBlazor.Color.Secondary => "var(--mud-palette-secondary)",
|
|
MudBlazor.Color.Success => "var(--mud-palette-success)",
|
|
MudBlazor.Color.Warning => "var(--mud-palette-warning)",
|
|
MudBlazor.Color.Error => "var(--mud-palette-error)",
|
|
MudBlazor.Color.Info => "var(--mud-palette-info)",
|
|
_ => "var(--mud-palette-primary)"
|
|
};
|
|
}
|
|
|
|
private string GetBackgroundStrokeDashArray()
|
|
{
|
|
double circumference = 2 * Math.PI * 35; // radius = 35
|
|
double maxArc = circumference * 0.75; // 270 degrees
|
|
return $"{maxArc} {circumference}";
|
|
}
|
|
|
|
private string GetStrokeDashArray()
|
|
{
|
|
double circumference = 2 * Math.PI * 35; // radius = 35
|
|
double maxArc = circumference * 0.75; // 270 degrees
|
|
double valueArc = maxArc * GetNormalizedValue();
|
|
return $"{valueArc} {circumference}";
|
|
}
|
|
|
|
private string GetStrokeDashOffset()
|
|
{
|
|
// No offset needed - arc should start from the beginning and grow
|
|
return "0";
|
|
}
|
|
|
|
private string GetPointerX()
|
|
{
|
|
double angle = -225 + (270 * GetNormalizedValue()); // -225 to +45 degrees (centered on vertical)
|
|
double radians = angle * Math.PI / 180;
|
|
double x = 50 + (25 * Math.Cos(radians)); // radius = 25 for pointer
|
|
return x.ToString("F1", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private string GetPointerY()
|
|
{
|
|
double angle = -225 + (270 * GetNormalizedValue());
|
|
double radians = angle * Math.PI / 180;
|
|
double y = 50 + (25 * Math.Sin(radians));
|
|
return y.ToString("F1", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private async Task OnPointerDown(PointerEventArgs e)
|
|
{
|
|
_isDragging = true;
|
|
_lastMouseY = e.ClientY;
|
|
_dragValue = Value; // Initialize drag value with current value
|
|
|
|
// 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();
|
|
}
|
|
|
|
// Delivered to the knob element because setPointerCapture routes the captured pointer here.
|
|
private async Task OnPointerMove(PointerEventArgs e)
|
|
{
|
|
if (_isDragging)
|
|
{
|
|
await UpdateValueFromPointer(e);
|
|
}
|
|
}
|
|
|
|
// pointerup implicitly releases pointer capture — no explicit releasePointerCapture needed.
|
|
private async Task OnPointerUp(PointerEventArgs e)
|
|
{
|
|
if (_isDragging && HoldValue)
|
|
{
|
|
// If HoldValue is enabled, emit the final value now
|
|
if (Math.Abs(_dragValue - Value) > 0.001)
|
|
{
|
|
await ValueChanged.InvokeAsync(_dragValue);
|
|
}
|
|
}
|
|
|
|
_isDragging = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
// Pointer capture cancelled by OS (e.g. Alt+Tab, system gesture) — end drag cleanly.
|
|
// 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
|
|
double deltaY = _lastMouseY - e.ClientY; // Inverted: up = positive, down = negative
|
|
_lastMouseY = e.ClientY;
|
|
|
|
// Sensitivity factor (pixels to move for full range)
|
|
double sensitivity = 100.0;
|
|
|
|
// Calculate change in normalized value based on vertical movement
|
|
double normalizedDelta = deltaY / sensitivity;
|
|
double currentNormalized = GetNormalizedValue();
|
|
double newNormalized = Math.Max(0, Math.Min(1, currentNormalized + normalizedDelta));
|
|
|
|
// Convert to actual value
|
|
double newValue = Min + (newNormalized * (Max - Min));
|
|
|
|
// Apply step
|
|
if (Step > 0)
|
|
{
|
|
newValue = Math.Round(newValue / Step) * Step;
|
|
}
|
|
|
|
if (Math.Abs(newValue - (HoldValue ? _dragValue : Value)) > 0.001) // Avoid unnecessary updates
|
|
{
|
|
if (HoldValue)
|
|
{
|
|
// Update drag value only, don't emit ValueChanged yet
|
|
_dragValue = newValue;
|
|
StateHasChanged(); // Update visual only
|
|
}
|
|
else
|
|
{
|
|
// Original behavior - emit ValueChanged immediately
|
|
Value = newValue;
|
|
await ValueChanged.InvokeAsync(Value);
|
|
}
|
|
}
|
|
}
|
|
} |