diff --git a/SkiaSharp.Extended.sln b/SkiaSharp.Extended.sln index 2a132310f3..712a8a68b5 100644 --- a/SkiaSharp.Extended.sln +++ b/SkiaSharp.Extended.sln @@ -21,6 +21,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Extended.UI.Maui. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharpDemo.Blazor", "samples\SkiaSharpDemo.Blazor\SkiaSharpDemo.Blazor.csproj", "{B7E4C45C-5CAB-444E-B2D3-294151544256}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Extended.UI.Blazor", "source\SkiaSharp.Extended.UI.Blazor\SkiaSharp.Extended.UI.Blazor.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Extended.UI.Blazor.Tests", "tests\SkiaSharp.Extended.UI.Blazor.Tests\SkiaSharp.Extended.UI.Blazor.Tests.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,6 +57,14 @@ Global {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,6 +76,8 @@ Global {2C67033A-2C49-4146-B942-9CDD2E0BA412} = {51B0C2C7-732B-4A5C-A4F2-55655D147866} {4B4EC78C-33B5-456D-BD7D-4358D16272F4} = {5555F827-12DF-4D15-BF07-3A720FC2EF3F} {B7E4C45C-5CAB-444E-B2D3-294151544256} = {51B0C2C7-732B-4A5C-A4F2-55655D147866} + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {5DEC7961-7CE3-44D7-A7FC-6185BA2D37FE} + {C3D4E5F6-A7B8-9012-CDEF-123456789012} = {5555F827-12DF-4D15-BF07-3A720FC2EF3F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {08D78153-5DD7-4C52-A348-46AA448B2CFC} diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor index 9f6dd7a922..adbbdaf4df 100644 --- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor +++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor @@ -32,6 +32,14 @@ BlurHash + + + + diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css index d3ebbd6cb2..a33df6dee3 100644 --- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css +++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css @@ -37,6 +37,10 @@ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'/%3E%3Cpath d='M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z'/%3E%3C/svg%3E"); } +.bi-hand-index-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666L3.4 8.18a.978.978 0 0 1 .56-1.048 .636.636 0 0 1 .544.025l.082.04c.258.126.458.265.575.37V1.75A.75.75 0 0 1 6.75 1z'/%3E%3C/svg%3E"); +} + .nav-group-header { font-size: 0.7rem; font-weight: 600; diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor b/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor new file mode 100644 index 0000000000..f008d792b7 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor @@ -0,0 +1,236 @@ +@page "/touch" + +Touch + +

Touch Events

+ +

+ Draw on the canvas below with mouse, finger, or stylus. + Each device type draws in a different color. Action counters and an event log update live. +

+ +
+ + Strokes: @_strokes.Count +
+ +
+ +
+ +
+
+
Device Types
+
+ @foreach (var device in DeviceColors) + { + var count = _deviceCounts.GetValueOrDefault(device.Name); +
+ +
@device.Name
+
@count
+
+ } +
+
+
+
Touch Actions
+
+ @foreach (var action in ActionNames) + { + var count = _actionCounts.GetValueOrDefault(action); + var active = _lastAction == action; +
+
@action
+
@count
+
+ } +
+
+
+ +
+
Event Log
+
+ @foreach (var entry in _log) + { +
@entry
+ } + @if (_log.Count == 0) + { +
Interact with the canvas…
+ } +
+
+ +@code { + // ── Canvas reference ── + + private SKTouchCanvasView? _canvasView; + + // ── Colors per device type ── + + private static readonly (string Name, string Css, SKColor Sk)[] DeviceColors = + { + ("Mouse", "#6495ED", new SKColor(0xFF6495ED)), // CornflowerBlue + ("Touch", "#FF7F50", new SKColor(0xFFFF7F50)), // Coral + ("Stylus", "#3CB371", new SKColor(0xFF3CB371)), // MediumSeaGreen + }; + + private static readonly string[] ActionNames = + { "Entered", "Pressed", "Moved", "Released", "Exited", "Cancelled", "WheelChanged" }; + + // ── Stroke data ── + + private record struct StrokeSegment(SKPoint Point); + + private class Stroke + { + public SKTouchDeviceType DeviceType { get; init; } + public List Points { get; } = new(); + } + + private readonly List _strokes = new(); + private readonly Dictionary _activeStrokes = new(); + + // ── Counters ── + + private readonly Dictionary _actionCounts = new(); + private readonly Dictionary _deviceCounts = new(); + private string _lastAction = ""; + + // ── Event log ── + + private const int MaxLogEntries = 80; + private readonly List _log = new(); + + // ── Touch handler ── + + private void OnTouch(SKTouchEventArgs e) + { + // Count action + var actionName = e.ActionType.ToString(); + _actionCounts[actionName] = _actionCounts.GetValueOrDefault(actionName) + 1; + _lastAction = actionName; + + // Count device + var deviceName = e.DeviceType.ToString(); + _deviceCounts[deviceName] = _deviceCounts.GetValueOrDefault(deviceName) + 1; + + // Log (newest first, cap at MaxLogEntries) + var logText = $"{actionName,-14} {deviceName,-7} ({e.Location.X:F0}, {e.Location.Y:F0})"; + if (e.ActionType == SKTouchAction.WheelChanged) + logText += $" Δ={e.WheelDelta}"; + _log.Insert(0, logText); + if (_log.Count > MaxLogEntries) + _log.RemoveAt(_log.Count - 1); + + // Scale CSS-pixel coordinates to canvas pixels (device DPI). + // Touch events arrive in CSS pixels, but the SkiaSharp surface + // renders at device pixel ratio when IgnorePixelScaling is false. + var dpi = _canvasView?.Dpi ?? 1f; + var scaledLocation = new SKPoint(e.Location.X * dpi, e.Location.Y * dpi); + + // Drawing strokes + switch (e.ActionType) + { + case SKTouchAction.Pressed: + var stroke = new Stroke { DeviceType = e.DeviceType }; + stroke.Points.Add(scaledLocation); + _strokes.Add(stroke); + _activeStrokes[e.Id] = stroke; + break; + + case SKTouchAction.Moved: + if (_activeStrokes.TryGetValue(e.Id, out var active)) + active.Points.Add(scaledLocation); + break; + + case SKTouchAction.Released: + case SKTouchAction.Cancelled: + if (_activeStrokes.TryGetValue(e.Id, out var finished)) + { + finished.Points.Add(scaledLocation); + _activeStrokes.Remove(e.Id); + } + break; + } + + e.Handled = true; + _canvasView?.Invalidate(); + } + + // ── Paint ── + + private void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.White); + + // Scale stroke width and legend by DPI so they look correct at device resolution + var dpi = _canvasView?.Dpi ?? 1f; + + using var paint = new SKPaint + { + IsAntialias = true, + StrokeWidth = 3 * dpi, + Style = SKPaintStyle.Stroke, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round, + }; + + foreach (var stroke in _strokes) + { + if (stroke.Points.Count < 2) + continue; + + paint.Color = GetDeviceColor(stroke.DeviceType); + + using var path = new SKPath(); + path.MoveTo(stroke.Points[0]); + for (int i = 1; i < stroke.Points.Count; i++) + path.LineTo(stroke.Points[i]); + canvas.DrawPath(path, paint); + } + + // Legend in top-left corner (scaled by DPI) + using var legendFont = new SKFont { Size = 12 * dpi, Edging = SKFontEdging.SubpixelAntialias }; + using var legendPaint = new SKPaint { IsAntialias = true }; + var y = 16f * dpi; + foreach (var (name, _, color) in DeviceColors) + { + legendPaint.Color = color; + canvas.DrawCircle(12 * dpi, y - 4 * dpi, 5 * dpi, legendPaint); + legendPaint.Color = SKColors.Black; + canvas.DrawText(name, 22 * dpi, y, SKTextAlign.Left, legendFont, legendPaint); + y += 18 * dpi; + } + } + + private static SKColor GetDeviceColor(SKTouchDeviceType type) => type switch + { + SKTouchDeviceType.Mouse => DeviceColors[0].Sk, + SKTouchDeviceType.Touch => DeviceColors[1].Sk, + SKTouchDeviceType.Stylus => DeviceColors[2].Sk, + _ => SKColors.Gray, + }; + + // ── Clear ── + + private void Clear() + { + _strokes.Clear(); + _activeStrokes.Clear(); + _actionCounts.Clear(); + _deviceCounts.Clear(); + _lastAction = ""; + _log.Clear(); + _canvasView?.Invalidate(); + } + + private void ClearLog() => _log.Clear(); +} diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor.css b/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor.css new file mode 100644 index 0000000000..5c3607c9d9 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Pages/Touch.razor.css @@ -0,0 +1,91 @@ +.toolbar { + display: flex; + align-items: center; + margin-bottom: 0.5rem; +} + +.canvas-container { + width: 100%; + height: 480px; + border: 1px solid #ccc; + border-radius: 6px; + overflow: hidden; + margin-bottom: 1rem; + touch-action: none; +} + +.stats-row { + display: flex; + gap: 2rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.stats-group { + flex: 1; + min-width: 260px; +} + +.stats-header { + font-weight: 600; + font-size: 0.85rem; + margin-bottom: 0.4rem; + color: #555; +} + +.event-grid { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.event-card { + border: 1px solid #ddd; + border-radius: 4px; + padding: 0.35rem 0.75rem; + min-width: 90px; + text-align: center; + transition: background-color 0.15s, border-color 0.15s; +} + +.event-card.active { + background-color: #e0f0ff; + border-color: #3399ff; +} + +.color-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-bottom: -1px; +} + +.event-name { + font-weight: 600; + font-size: 0.8rem; +} + +.event-count { + font-size: 1.1rem; + color: #333; +} + +.log-section { + margin-bottom: 2rem; +} + +.event-log { + font-family: monospace; + font-size: 0.78rem; + max-height: 160px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 4px; + padding: 0.4rem 0.6rem; + background: #fafafa; +} + +.log-entry { + white-space: nowrap; +} diff --git a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj index e22a8ae99e..6f57739f6d 100644 --- a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj +++ b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj @@ -33,6 +33,7 @@ + diff --git a/samples/SkiaSharpDemo.Blazor/_Imports.razor b/samples/SkiaSharpDemo.Blazor/_Imports.razor index 10f52e8c0e..a7dcbf3cf7 100644 --- a/samples/SkiaSharpDemo.Blazor/_Imports.razor +++ b/samples/SkiaSharpDemo.Blazor/_Imports.razor @@ -9,5 +9,7 @@ @using SkiaSharp @using SkiaSharp.Extended @using SkiaSharp.Views.Blazor +@using SkiaSharp.Extended.UI.Blazor +@using SkiaSharp.Extended.UI.Blazor.Controls @using SkiaSharpDemo.Blazor @using SkiaSharpDemo.Blazor.Layout diff --git a/scripts/SkiaSharp.Extended-Pack.slnf b/scripts/SkiaSharp.Extended-Pack.slnf index c8b1cf8142..f26c00b929 100644 --- a/scripts/SkiaSharp.Extended-Pack.slnf +++ b/scripts/SkiaSharp.Extended-Pack.slnf @@ -2,6 +2,7 @@ "solution": { "path": "..\\SkiaSharp.Extended.sln", "projects": [ + "source\\SkiaSharp.Extended.UI.Blazor\\SkiaSharp.Extended.UI.Blazor.csproj", "source\\SkiaSharp.Extended.UI.Maui\\SkiaSharp.Extended.UI.Maui.csproj", "source\\SkiaSharp.Extended\\SkiaSharp.Extended.csproj", ] diff --git a/scripts/SkiaSharp.Extended-Test.slnf b/scripts/SkiaSharp.Extended-Test.slnf index 3fb62aed78..35db2aa96a 100644 --- a/scripts/SkiaSharp.Extended-Test.slnf +++ b/scripts/SkiaSharp.Extended-Test.slnf @@ -2,9 +2,11 @@ "solution": { "path": "..\\SkiaSharp.Extended.sln", "projects": [ + "source\\SkiaSharp.Extended.UI.Blazor\\SkiaSharp.Extended.UI.Blazor.csproj", "source\\SkiaSharp.Extended.UI.Maui\\SkiaSharp.Extended.UI.Maui.csproj", "source\\SkiaSharp.Extended\\SkiaSharp.Extended.csproj", "tests\\SkiaSharp.Extended.Tests\\SkiaSharp.Extended.Tests.csproj", + "tests\\SkiaSharp.Extended.UI.Blazor.Tests\\SkiaSharp.Extended.UI.Blazor.Tests.csproj", "tests\\SkiaSharp.Extended.UI.Maui.Tests\\SkiaSharp.Extended.UI.Maui.Tests.csproj", ] } diff --git a/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor b/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor new file mode 100644 index 0000000000..c2d4ce81b3 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor @@ -0,0 +1,10 @@ +@namespace SkiaSharp.Extended.UI.Blazor.Controls +@inherits ComponentBase +@implements IAsyncDisposable + + diff --git a/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor.cs b/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor.cs new file mode 100644 index 0000000000..909ac99cd5 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/Controls/SKTouchCanvasView.razor.cs @@ -0,0 +1,208 @@ +using Microsoft.JSInterop; +using SkiaSharp.Views.Blazor; + +namespace SkiaSharp.Extended.UI.Blazor.Controls; + +/// +/// A SkiaSharp canvas view for Blazor that adds touch/pointer event support +/// using the same API as the MAUI SKCanvasView. +/// +/// +/// This view translates browser pointer events (mouse, touch, stylus) into +/// and raises the event, +/// matching the MAUI touch API for shared source compatibility. +/// Pointer events are captured using JavaScript interop on the canvas element. +/// No wrapper element is added; the upstream SKCanvasView splats +/// AdditionalAttributes directly onto its <canvas>, so a +/// data-sk-touch-id attribute is used to locate the element from JS. +/// +public partial class SKTouchCanvasView : ComponentBase, IAsyncDisposable +{ + private SkiaSharp.Views.Blazor.SKCanvasView? _skCanvasView; + private readonly string _touchId = Guid.NewGuid().ToString("N"); + private IJSObjectReference? _jsModule; + private DotNetObjectReference? _dotNetRef; + private bool _touchInitialized; + private bool _disposed; + + [Inject] + private IJSRuntime JSRuntime { get; set; } = default!; + + /// Gets or sets the paint surface callback. + [Parameter] + public Action? OnPaintSurface { get; set; } + + /// + /// Gets or sets the touch event callback. + /// This event is raised for all pointer interactions (mouse, touch, stylus). + /// + [Parameter] + public EventCallback Touch { get; set; } + + /// Gets or sets whether touch events are enabled. + [Parameter] + public bool EnableTouchEvents { get; set; } = true; + + /// Gets or sets whether continuous rendering is enabled. + [Parameter] + public bool EnableRenderLoop { get; set; } + + /// Gets or sets whether pixel scaling is ignored. + [Parameter] + public bool IgnorePixelScaling { get; set; } + + /// Gets or sets additional HTML attributes passed to the inner canvas. + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// Gets the current DPI of the display. + public float Dpi => _skCanvasView is not null ? (float)_skCanvasView.Dpi : 1f; + + /// Requests a redraw of the canvas. + public void Invalidate() => _skCanvasView?.Invalidate(); + + /// + /// Merges the user-supplied with the + /// internal data-sk-touch-id marker attribute so JS interop can locate + /// the rendered <canvas> element without a wrapper div. + /// + private IReadOnlyDictionary MergedAttributes + { + get + { + var attrs = new Dictionary(); + + if (AdditionalAttributes is not null) + { + foreach (var kvp in AdditionalAttributes) + attrs[kvp.Key] = kvp.Value; + } + + // Set after user attributes so it cannot be overwritten + attrs["data-sk-touch-id"] = _touchId; + + return attrs; + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender && EnableTouchEvents) + { + await InitializeTouchAsync(); + } + } + + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + if (_touchInitialized && !EnableTouchEvents) + { + await DisposeTouchAsync(); + } + } + + private async Task InitializeTouchAsync() + { + if (_touchInitialized) + return; + + try + { + _jsModule = await JSRuntime.InvokeAsync( + "import", "./_content/SkiaSharp.Extended.UI.Blazor/SKTouchInterop.js"); + + _dotNetRef = DotNetObjectReference.Create(this); + + await _jsModule.InvokeVoidAsync("initializeTouchEvents", _touchId, _dotNetRef); + + _touchInitialized = true; + } + catch (Exception ex) when (ex is JSException || ex is InvalidOperationException) + { + // JS interop may not be available in pre-rendering scenarios + } + } + + private async Task DisposeTouchAsync() + { + _touchInitialized = false; + if (_jsModule is not null) + { + try + { + await _jsModule.InvokeVoidAsync("disposeTouchEvents", _touchId); + await _jsModule.DisposeAsync(); + _jsModule = null; + } + catch (JSDisconnectedException) { } + catch (JSException) { } + } + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + + /// + /// Invoked from JavaScript when a pointer event occurs. + /// + [JSInvokable] + public async Task OnPointerEvent(PointerEventData data) + { + if (!EnableTouchEvents || !Touch.HasDelegate) + return; + + var args = new SKTouchEventArgs( + id: data.Id, + actionType: (SKTouchAction)data.Action, + deviceType: (SKTouchDeviceType)data.DeviceType, + location: new SKPoint(data.X, data.Y), + inContact: data.InContact, + pressure: data.Pressure, + wheelDelta: data.WheelDelta); + + await Touch.InvokeAsync(args); + } + + private void OnPaintSurfaceInternal(SKPaintSurfaceEventArgs e) + { + OnPaintSurface?.Invoke(e); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + + await DisposeTouchAsync(); + // Do not manually dispose _skCanvasView – Blazor manages its lifecycle. + // Disposing it here before the DOM is cleaned up causes ResizeObserver errors. + } + + /// Data transfer object for pointer events from JavaScript. + public sealed class PointerEventData + { + /// Gets or sets the pointer ID. + public long Id { get; set; } + /// Gets or sets the action type (maps to ). + public int Action { get; set; } + /// Gets or sets the device type (maps to ). + public int DeviceType { get; set; } + /// Gets or sets the X coordinate in CSS pixels. + public float X { get; set; } + /// Gets or sets the Y coordinate in CSS pixels. + public float Y { get; set; } + /// Gets or sets the pressure (0.0–1.0). + public float Pressure { get; set; } + /// Gets or sets whether the pointer is in contact. + public bool InContact { get; set; } + /// Gets or sets the wheel delta. + public int WheelDelta { get; set; } + } +} diff --git a/source/SkiaSharp.Extended.UI.Blazor/SkiaSharp.Extended.UI.Blazor.csproj b/source/SkiaSharp.Extended.UI.Blazor/SkiaSharp.Extended.UI.Blazor.csproj new file mode 100644 index 0000000000..a4af0e2c06 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/SkiaSharp.Extended.UI.Blazor.csproj @@ -0,0 +1,48 @@ + + + + net9.0;net10.0 + enable + + SkiaSharp.Extended.UI.Blazor + SkiaSharp.Extended.UI.Blazor + false + Default + + + + SkiaSharp.Extended.UI.Blazor + Additional SkiaSharp controls for Blazor + This package adds a touch-enabled SkiaSharp canvas view for Blazor, providing unified touch/pointer event support across mouse, touch, and stylus inputs. + + + + es2015 + es2015 + True + True + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchAction.cs b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchAction.cs new file mode 100644 index 0000000000..684fd2d288 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchAction.cs @@ -0,0 +1,23 @@ +namespace SkiaSharp.Extended.UI.Blazor; + +/// +/// Specifies the action that caused a touch event. +/// Matches the SkiaSharp.Views.Maui SKTouchAction API for source sharing. +/// +public enum SKTouchAction +{ + /// The touch was cancelled. + Cancelled = 0, + /// The pointer entered the element. + Entered = 1, + /// The touch started (pointer down). + Pressed = 2, + /// The touch moved. + Moved = 3, + /// The touch ended (pointer up). + Released = 4, + /// The pointer exited the element. + Exited = 5, + /// The mouse wheel changed. + WheelChanged = 6, +} diff --git a/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchDeviceType.cs b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchDeviceType.cs new file mode 100644 index 0000000000..582e3c8911 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchDeviceType.cs @@ -0,0 +1,15 @@ +namespace SkiaSharp.Extended.UI.Blazor; + +/// +/// Specifies the type of device that caused a touch event. +/// Matches the SkiaSharp.Views.Maui SKTouchDeviceType API for source sharing. +/// +public enum SKTouchDeviceType +{ + /// A touch input device (finger). + Touch = 0, + /// A mouse input device. + Mouse = 1, + /// A stylus/pen input device. + Stylus = 2, +} diff --git a/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchEventArgs.cs b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchEventArgs.cs new file mode 100644 index 0000000000..873b3b4376 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/Touch/SKTouchEventArgs.cs @@ -0,0 +1,56 @@ +namespace SkiaSharp.Extended.UI.Blazor; + +/// +/// Provides data for the event. +/// Uses the same API as SkiaSharp.Views.Maui SKTouchEventArgs for source sharing. +/// +public class SKTouchEventArgs : EventArgs +{ + /// + /// Initializes a new instance of . + /// + public SKTouchEventArgs( + long id, + SKTouchAction actionType, + SKTouchDeviceType deviceType, + SKPoint location, + bool inContact, + float pressure = 1f, + int wheelDelta = 0) + { + Id = id; + ActionType = actionType; + DeviceType = deviceType; + Location = location; + InContact = inContact; + Pressure = pressure; + WheelDelta = wheelDelta; + } + + /// Gets the unique identifier for this touch point. + public long Id { get; } + + /// Gets the action type that caused this event. + public SKTouchAction ActionType { get; } + + /// Gets the type of input device. + public SKTouchDeviceType DeviceType { get; } + + /// Gets the location of the touch point in canvas coordinates (points, not pixels). + public SKPoint Location { get; } + + /// Gets a value indicating whether the touch point is in contact with the surface. + public bool InContact { get; } + + /// Gets the pressure of the touch (0.0–1.0). + public float Pressure { get; } + + /// Gets the mouse wheel delta (only valid for ). + public int WheelDelta { get; } + + /// + /// Gets or sets whether this event has been handled. + /// Set to true to prevent further processing. + /// + public bool Handled { get; set; } +} diff --git a/source/SkiaSharp.Extended.UI.Blazor/_Imports.razor b/source/SkiaSharp.Extended.UI.Blazor/_Imports.razor new file mode 100644 index 0000000000..06139a8039 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/_Imports.razor @@ -0,0 +1,6 @@ +@using SkiaSharp.Extended.UI.Blazor +@using SkiaSharp.Extended.UI.Blazor.Controls +@using SkiaSharp.Views.Blazor +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop diff --git a/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js new file mode 100644 index 0000000000..91ff08153a --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js @@ -0,0 +1,109 @@ +// SKTouchInterop.ts +// Handles pointer events on the SkiaSharp canvas and forwards them to .NET +// using the same event model as the MAUI SKTouchEventArgs API. +let currentElement = null; +let currentDotNetRef = null; +function findElement(touchId) { + return document.querySelector(`[data-sk-touch-id="${touchId}"]`); +} +export function initializeTouchEvents(touchId, dotNetRef) { + const element = findElement(touchId); + if (!element) + return; + currentElement = element; + currentDotNetRef = dotNetRef; + element.style.touchAction = "none"; + element.style.userSelect = "none"; + element.addEventListener("pointerdown", onPointerDown); + element.addEventListener("pointermove", onPointerMove); + element.addEventListener("pointerup", onPointerUp); + element.addEventListener("pointercancel", onPointerCancel); + element.addEventListener("pointerenter", onPointerEnter); + element.addEventListener("pointerleave", onPointerLeave); + element.addEventListener("wheel", onWheel); +} +export function disposeTouchEvents(touchId) { + const element = findElement(touchId); + if (!element) + return; + element.style.touchAction = ""; + element.style.userSelect = ""; + element.removeEventListener("pointerdown", onPointerDown); + element.removeEventListener("pointermove", onPointerMove); + element.removeEventListener("pointerup", onPointerUp); + element.removeEventListener("pointercancel", onPointerCancel); + element.removeEventListener("pointerenter", onPointerEnter); + element.removeEventListener("pointerleave", onPointerLeave); + element.removeEventListener("wheel", onWheel); + currentElement = null; + currentDotNetRef = null; +} +function onPointerDown(e) { + sendPointerEvent(e, 2 /* SKTouchAction.Pressed */); + try { + e.currentTarget.setPointerCapture(e.pointerId); + } + catch ( /* ignore */_a) { /* ignore */ } +} +function onPointerMove(e) { + sendPointerEvent(e, 3 /* SKTouchAction.Moved */); +} +function onPointerUp(e) { + sendPointerEvent(e, 4 /* SKTouchAction.Released */); +} +function onPointerCancel(e) { + sendPointerEvent(e, 0 /* SKTouchAction.Cancelled */); +} +function onPointerEnter(e) { + sendPointerEvent(e, 1 /* SKTouchAction.Entered */); +} +function onPointerLeave(e) { + sendPointerEvent(e, 5 /* SKTouchAction.Exited */); +} +function onWheel(e) { + if (!currentDotNetRef) + return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const delta = e.deltaMode === 0 + ? Math.round(-e.deltaY / 10) + : (e.deltaY < 0 ? 1 : -1); + currentDotNetRef.invokeMethodAsync("OnPointerEvent", { + id: -1, + action: 6 /* SKTouchAction.WheelChanged */, + deviceType: 1 /* SKTouchDeviceType.Mouse */, + x, + y, + pressure: 0, + inContact: false, + wheelDelta: delta, + }); + e.preventDefault(); +} +function getDeviceType(pointerType) { + switch (pointerType) { + case "mouse": return 1 /* SKTouchDeviceType.Mouse */; + case "pen": return 2 /* SKTouchDeviceType.Stylus */; + default: return 0 /* SKTouchDeviceType.Touch */; + } +} +function sendPointerEvent(e, action) { + if (!currentDotNetRef) + return; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const deviceType = getDeviceType(e.pointerType); + const inContact = e.buttons !== 0 || action === 2 /* SKTouchAction.Pressed */; + currentDotNetRef.invokeMethodAsync("OnPointerEvent", { + id: e.pointerId, + action, + deviceType, + x, + y, + pressure: e.pressure, + inContact, + }); +} +//# sourceMappingURL=SKTouchInterop.js.map \ No newline at end of file diff --git a/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js.map b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js.map new file mode 100644 index 0000000000..7edab005ae --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.js.map @@ -0,0 +1 @@ +{"version":3,"file":"SKTouchInterop.js","sourceRoot":"","sources":["SKTouchInterop.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.ts b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.ts new file mode 100644 index 0000000000..2c048eafe5 --- /dev/null +++ b/source/SkiaSharp.Extended.UI.Blazor/wwwroot/SKTouchInterop.ts @@ -0,0 +1,157 @@ +// SKTouchInterop.ts +// Handles pointer events on the SkiaSharp canvas and forwards them to .NET +// using the same event model as the MAUI SKTouchEventArgs API. + +interface DotNetObjectReference { + invokeMethodAsync(methodName: string, args: PointerEventPayload): void; +} + +interface PointerEventPayload { + id: number; + action: number; + deviceType: number; + x: number; + y: number; + pressure: number; + inContact: boolean; + wheelDelta?: number; +} + +const enum SKTouchAction { + Cancelled = 0, + Entered = 1, + Pressed = 2, + Moved = 3, + Released = 4, + Exited = 5, + WheelChanged = 6, +} + +const enum SKTouchDeviceType { + Touch = 0, + Mouse = 1, + Stylus = 2, +} + +let currentElement: HTMLElement | null = null; +let currentDotNetRef: DotNetObjectReference | null = null; + +function findElement(touchId: string): HTMLElement | null { + return document.querySelector(`[data-sk-touch-id="${touchId}"]`); +} + +export function initializeTouchEvents(touchId: string, dotNetRef: DotNetObjectReference): void { + const element = findElement(touchId); + if (!element) return; + + currentElement = element; + currentDotNetRef = dotNetRef; + + element.style.touchAction = "none"; + element.style.userSelect = "none"; + + element.addEventListener("pointerdown", onPointerDown); + element.addEventListener("pointermove", onPointerMove); + element.addEventListener("pointerup", onPointerUp); + element.addEventListener("pointercancel", onPointerCancel); + element.addEventListener("pointerenter", onPointerEnter); + element.addEventListener("pointerleave", onPointerLeave); + element.addEventListener("wheel", onWheel); +} + +export function disposeTouchEvents(touchId: string): void { + const element = findElement(touchId); + if (!element) return; + + element.style.touchAction = ""; + element.style.userSelect = ""; + + element.removeEventListener("pointerdown", onPointerDown); + element.removeEventListener("pointermove", onPointerMove); + element.removeEventListener("pointerup", onPointerUp); + element.removeEventListener("pointercancel", onPointerCancel); + element.removeEventListener("pointerenter", onPointerEnter); + element.removeEventListener("pointerleave", onPointerLeave); + element.removeEventListener("wheel", onWheel); + + currentElement = null; + currentDotNetRef = null; +} + +function onPointerDown(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Pressed); + try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch { /* ignore */ } +} + +function onPointerMove(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Moved); +} + +function onPointerUp(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Released); +} + +function onPointerCancel(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Cancelled); +} + +function onPointerEnter(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Entered); +} + +function onPointerLeave(e: PointerEvent): void { + sendPointerEvent(e, SKTouchAction.Exited); +} + +function onWheel(e: WheelEvent): void { + if (!currentDotNetRef) return; + + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const delta = e.deltaMode === 0 + ? Math.round(-e.deltaY / 10) + : (e.deltaY < 0 ? 1 : -1); + + currentDotNetRef.invokeMethodAsync("OnPointerEvent", { + id: -1, + action: SKTouchAction.WheelChanged, + deviceType: SKTouchDeviceType.Mouse, + x, + y, + pressure: 0, + inContact: false, + wheelDelta: delta, + }); + + e.preventDefault(); +} + +function getDeviceType(pointerType: string): SKTouchDeviceType { + switch (pointerType) { + case "mouse": return SKTouchDeviceType.Mouse; + case "pen": return SKTouchDeviceType.Stylus; + default: return SKTouchDeviceType.Touch; + } +} + +function sendPointerEvent(e: PointerEvent, action: SKTouchAction): void { + if (!currentDotNetRef) return; + + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const deviceType = getDeviceType(e.pointerType); + const inContact = e.buttons !== 0 || action === SKTouchAction.Pressed; + + currentDotNetRef.invokeMethodAsync("OnPointerEvent", { + id: e.pointerId, + action, + deviceType, + x, + y, + pressure: e.pressure, + inContact, + }); +} diff --git a/tests/SkiaSharp.Extended.UI.Blazor.Tests/SKTouchEventArgsTest.cs b/tests/SkiaSharp.Extended.UI.Blazor.Tests/SKTouchEventArgsTest.cs new file mode 100644 index 0000000000..0d43f3f9d4 --- /dev/null +++ b/tests/SkiaSharp.Extended.UI.Blazor.Tests/SKTouchEventArgsTest.cs @@ -0,0 +1,120 @@ +namespace SkiaSharp.Extended.UI.Blazor.Tests; + +public class SKTouchEventArgsTest +{ + [Fact] + public void Constructor_SetsAllProperties() + { + var location = new SKPoint(100, 200); + + var args = new SKTouchEventArgs( + id: 42, + actionType: SKTouchAction.Pressed, + deviceType: SKTouchDeviceType.Touch, + location: location, + inContact: true, + pressure: 0.8f, + wheelDelta: 0); + + Assert.Equal(42, args.Id); + Assert.Equal(SKTouchAction.Pressed, args.ActionType); + Assert.Equal(SKTouchDeviceType.Touch, args.DeviceType); + Assert.Equal(location, args.Location); + Assert.True(args.InContact); + Assert.Equal(0.8f, args.Pressure); + Assert.Equal(0, args.WheelDelta); + Assert.False(args.Handled); + } + + [Fact] + public void Handled_CanBeSetToTrue() + { + var args = new SKTouchEventArgs(1, SKTouchAction.Pressed, SKTouchDeviceType.Mouse, + new SKPoint(0, 0), false); + + args.Handled = true; + + Assert.True(args.Handled); + } + + [Fact] + public void WheelDelta_IsSetForWheelChangedAction() + { + var args = new SKTouchEventArgs( + id: -1, + actionType: SKTouchAction.WheelChanged, + deviceType: SKTouchDeviceType.Mouse, + location: new SKPoint(50, 50), + inContact: false, + pressure: 0f, + wheelDelta: 3); + + Assert.Equal(SKTouchAction.WheelChanged, args.ActionType); + Assert.Equal(3, args.WheelDelta); + } + + [Fact] + public void DefaultPressure_IsOne() + { + var args = new SKTouchEventArgs(1, SKTouchAction.Pressed, SKTouchDeviceType.Touch, + SKPoint.Empty, true); + + Assert.Equal(1f, args.Pressure); + } + + [Fact] + public void SKTouchAction_HasCorrectValues() + { + // These values must match MAUI's SKTouchAction enum for source sharing + Assert.Equal(0, (int)SKTouchAction.Cancelled); + Assert.Equal(1, (int)SKTouchAction.Entered); + Assert.Equal(2, (int)SKTouchAction.Pressed); + Assert.Equal(3, (int)SKTouchAction.Moved); + Assert.Equal(4, (int)SKTouchAction.Released); + Assert.Equal(5, (int)SKTouchAction.Exited); + Assert.Equal(6, (int)SKTouchAction.WheelChanged); + } + + [Fact] + public void SKTouchDeviceType_HasCorrectValues() + { + // These values must match MAUI's SKTouchDeviceType enum for source sharing + Assert.Equal(0, (int)SKTouchDeviceType.Touch); + Assert.Equal(1, (int)SKTouchDeviceType.Mouse); + Assert.Equal(2, (int)SKTouchDeviceType.Stylus); + } + + [Theory] + [InlineData(SKTouchAction.Pressed, SKTouchDeviceType.Touch)] + [InlineData(SKTouchAction.Moved, SKTouchDeviceType.Mouse)] + [InlineData(SKTouchAction.Released, SKTouchDeviceType.Stylus)] + [InlineData(SKTouchAction.Cancelled, SKTouchDeviceType.Touch)] + [InlineData(SKTouchAction.Entered, SKTouchDeviceType.Mouse)] + [InlineData(SKTouchAction.Exited, SKTouchDeviceType.Mouse)] + [InlineData(SKTouchAction.WheelChanged, SKTouchDeviceType.Mouse)] + public void Constructor_AcceptsAllActionAndDeviceTypeCombinations(SKTouchAction action, SKTouchDeviceType device) + { + var args = new SKTouchEventArgs(1, action, device, new SKPoint(10, 20), true); + + Assert.Equal(action, args.ActionType); + Assert.Equal(device, args.DeviceType); + } + + [Fact] + public void DefaultWheelDelta_IsZero() + { + var args = new SKTouchEventArgs(1, SKTouchAction.Pressed, SKTouchDeviceType.Touch, + new SKPoint(0, 0), true); + + Assert.Equal(0, args.WheelDelta); + } + + [Fact] + public void InheritsFromEventArgs() + { + var args = new SKTouchEventArgs(1, SKTouchAction.Pressed, SKTouchDeviceType.Touch, + SKPoint.Empty, true); + + Assert.IsAssignableFrom(args); + } +} diff --git a/tests/SkiaSharp.Extended.UI.Blazor.Tests/SkiaSharp.Extended.UI.Blazor.Tests.csproj b/tests/SkiaSharp.Extended.UI.Blazor.Tests/SkiaSharp.Extended.UI.Blazor.Tests.csproj new file mode 100644 index 0000000000..c531cc8a00 --- /dev/null +++ b/tests/SkiaSharp.Extended.UI.Blazor.Tests/SkiaSharp.Extended.UI.Blazor.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SkiaSharp.Extended.UI.Blazor.Tests/xunit.runner.json b/tests/SkiaSharp.Extended.UI.Blazor.Tests/xunit.runner.json new file mode 100644 index 0000000000..a7840670b8 --- /dev/null +++ b/tests/SkiaSharp.Extended.UI.Blazor.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "xunit.diagnosticMessages": true, + "xunit.methodDisplay": "classAndMethod" +}