diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index dca836c..6101978 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -7,33 +7,34 @@ on: branches: [ main ] jobs: - build: - + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 6.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 6.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Run Tests + run: dotnet test --no-build --verbosity normal + doxygen: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - name: Doxygen Action - # You may pin to the exact commit or the version. - # uses: mattnotmitt/doxygen-action@cdd5472f8e48e141b89d2633c1ae72991a21cb6a uses: mattnotmitt/doxygen-action@1.9.2 with: - # Path to Doxyfile doxyfile-path: ./Doxyfile - # Working directory working-directory: . diff --git a/BlazorObservers.sln b/BlazorObservers.sln index 2ff95b6..19ae61a 100644 --- a/BlazorObservers.sln +++ b/BlazorObservers.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ObserverLibrary", "Observer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObserverExample", "ObserverExample\ObserverExample.csproj", "{5F06C015-629D-432E-93E7-C6EA2FAA5ACD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObserverLibraryTests", "ObserverLibraryTests\ObserverLibraryTests.csproj", "{1A3B38A4-7739-49B6-AC27-EB0D916293B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F06C015-629D-432E-93E7-C6EA2FAA5ACD}.Release|Any CPU.Build.0 = Release|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A3B38A4-7739-49B6-AC27-EB0D916293B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ObserverExample/ObserverExample.csproj b/ObserverExample/ObserverExample.csproj index 7d32c32..1cf72ec 100644 --- a/ObserverExample/ObserverExample.csproj +++ b/ObserverExample/ObserverExample.csproj @@ -1,15 +1,15 @@ - net6.0 + net8.0 enable enable 0c6478ff-04a7-4fae-a5c5-413a99777770 - - + + diff --git a/ObserverExample/Pages/Counter.razor b/ObserverExample/Pages/Counter.razor deleted file mode 100644 index ef23cb3..0000000 --- a/ObserverExample/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/ObserverExample/Pages/IntersectionDivs.razor b/ObserverExample/Pages/IntersectionDivs.razor new file mode 100644 index 0000000..a5e39d6 --- /dev/null +++ b/ObserverExample/Pages/IntersectionDivs.razor @@ -0,0 +1,63 @@ +@page "/intersection" +@inject IntersectionObserverService IntersectionObserverService +@inject IJSRuntime JSRuntime +@implements IAsyncDisposable + +Intersection + +

Intersection Observer Demo

+ +
+
+
+
@(percentageLabel)%
+
@(percentageLabel)%
+
@(percentageLabel)%
+
@(percentageLabel)%
+
+
+
+ +@code { + + private ElementReference? scrollContainer; + private ElementReference? scrollContent; + private int redValue = 0; + private int percentageLabel = 0; + + private IntersectionTask? taskReference; + + private void OnIntersectionChange(JsIntersectionObserverEntry[] entries) + { + var entry = entries.Single(); + percentageLabel = (int)(entry.IntersectionRatio * 100); // 0-100 + redValue = (int)(entry.IntersectionRatio * 255); // 0-255 + StateHasChanged(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + + // create the intersection observer task + if (taskReference is null && scrollContainer.HasValue && scrollContent.HasValue) + { + // create the observer + taskReference = await IntersectionObserverService.RegisterObserver( + OnIntersectionChange, + new JsIntersectionObserverOptions { + Root = scrollContainer.Value, + Threshold = Enumerable.Range(0, 101).Select(x => x / 100d) // Trigger callback at every 1% change + }, + scrollContent.Value); + } + } + } + + public async ValueTask DisposeAsync() + { + if (taskReference is not null) + await IntersectionObserverService.DeregisterObserver(taskReference); + } +} diff --git a/ObserverExample/Pages/IntersectionDivs.razor.css b/ObserverExample/Pages/IntersectionDivs.razor.css new file mode 100644 index 0000000..8eb16b1 --- /dev/null +++ b/ObserverExample/Pages/IntersectionDivs.razor.css @@ -0,0 +1,55 @@ +.contents { + position: absolute; + width: 300px; + height: 1725px; + overflow-y: scroll; + max-height: 500px; + border: 1px solid #ccc; +} + +.wrapper { + position: relative; + top: 600px; +} + +.sampleBox { + position: relative; + left: 175px; + width: 150px; + background-color: rgb(245 170 140); + border: 2px solid rgb(201 126 17); + padding: 4px; + margin-bottom: 6px; +} + +.label { + font: 14px "Open Sans", "Arial", sans-serif; + position: absolute; + margin: 0; + color: white; + border: 1px solid rgb(0 0 0 / 70%); + width: 3em; + height: 18px; + padding: 2px; + text-align: center; +} + +.topLeft { + left: 2px; + top: 2px; +} + +.topRight { + right: 2px; + top: 2px; +} + +.bottomLeft { + bottom: 2px; + left: 2px; +} + +.bottomRight { + bottom: 2px; + right: 2px; +} diff --git a/ObserverExample/Shared/NavMenu.razor b/ObserverExample/Shared/NavMenu.razor index 479dabd..656d1a1 100644 --- a/ObserverExample/Shared/NavMenu.razor +++ b/ObserverExample/Shared/NavMenu.razor @@ -14,19 +14,14 @@ Home - diff --git a/ObserverExample/wwwroot/index.html b/ObserverExample/wwwroot/index.html index ce0b829..9b91151 100644 --- a/ObserverExample/wwwroot/index.html +++ b/ObserverExample/wwwroot/index.html @@ -20,6 +20,7 @@ 🗙 + diff --git a/ObserverLibrary/DI/DependencyInjectionExtensions.cs b/ObserverLibrary/DI/DependencyInjectionExtensions.cs index 7b743bf..e8f2a12 100644 --- a/ObserverLibrary/DI/DependencyInjectionExtensions.cs +++ b/ObserverLibrary/DI/DependencyInjectionExtensions.cs @@ -4,18 +4,20 @@ namespace BlazorObservers.ObserverLibrary.DI { /// - /// Contains shorthand methods to register ObserverRegistrationServices + /// Contains shorthand methods to register Observer services /// public static class DependencyInjectionExtensions { /// - /// Register a scoped ResizeObserverRegistrationService in the dependency injection + /// Register scoped observer services in the dependency injection /// /// /// public static IServiceCollection AddResizeObserverService(this IServiceCollection services) { - return services.AddScoped(); + return services + .AddScoped() + .AddScoped(); } } } diff --git a/ObserverLibrary/JsModels/JsDomRect.cs b/ObserverLibrary/JsModels/JsDomRect.cs new file mode 100644 index 0000000..8735fc2 --- /dev/null +++ b/ObserverLibrary/JsModels/JsDomRect.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +namespace BlazorObservers.ObserverLibrary.JsModels +{ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Model for DomRectReadOnly javascript objects + /// + public readonly struct JsDomRect + { + [JsonPropertyName("x")] + public double X { get; init; } + + [JsonPropertyName("y")] + public double Y { get; init; } + + [JsonPropertyName("width")] + public double Width { get; init; } + + [JsonPropertyName("height")] + public double Height { get; init; } + + [JsonPropertyName("top")] + public double Top { get; init; } + + [JsonPropertyName("right")] + public double Right { get; init; } + + [JsonPropertyName("bottom")] + public double Bottom { get; init; } + + [JsonPropertyName("left")] + public double Left { get; init; } + } + +} +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member \ No newline at end of file diff --git a/ObserverLibrary/JsModels/JsIntersectionObserverEntry.cs b/ObserverLibrary/JsModels/JsIntersectionObserverEntry.cs new file mode 100644 index 0000000..ac6be74 --- /dev/null +++ b/ObserverLibrary/JsModels/JsIntersectionObserverEntry.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Components; +using System.Text.Json.Serialization; + +namespace BlazorObservers.ObserverLibrary.JsModels +{ + /// + /// Model for callback parameter of the IntersectionObserver + /// + public class JsIntersectionObserverEntry + { + /// + /// A DOMRectReadOnly object describing the bounds rectangle of the target element. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/boundingClientRect + /// + [JsonPropertyName("boundingClientRect")] + public JsDomRect BoundingClientRect { get; set; } + + /// + /// A ratio representing the percentage of the target element that is visible. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/intersectionRatio + /// + [JsonPropertyName("intersectionRatio")] + public double IntersectionRatio { get; set; } + + /// + /// A DOMRectReadOnly representing the visible area of the target element. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/intersectionRect + /// + [JsonPropertyName("intersectionRect")] + public JsDomRect IntersectionRect { get; set; } + + /// + /// True if the target element is currently intersecting with the root. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/isIntersecting + /// + [JsonPropertyName("isIntersecting")] + public bool IsIntersecting { get; set; } + + /// + /// A DOMRectReadOnly representing the root's bounding box. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/rootBounds + /// + [JsonPropertyName("rootBounds")] + public JsDomRect? RootBounds { get; set; } + + /// + /// The Element whose intersection with the root has changed. + /// + public ElementReference? TargetElement { get; set; } + + /// + /// Tracking Id to match element from JS to ElementReference in C# + /// + [JsonPropertyName("targetTrackingId")] + public string? TargetElementTrackingId { get; set; } + + /// + /// A high-resolution timestamp indicating when the intersection was recorded. + /// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/time + /// + [JsonPropertyName("time")] + public double Time { get; set; } + } +} \ No newline at end of file diff --git a/ObserverLibrary/JsModels/JsIntersectionObserverOptions.cs b/ObserverLibrary/JsModels/JsIntersectionObserverOptions.cs new file mode 100644 index 0000000..382b19b --- /dev/null +++ b/ObserverLibrary/JsModels/JsIntersectionObserverOptions.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components; + +namespace BlazorObservers.ObserverLibrary.JsModels +{ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + + /// + /// Model for IntersectionObserver options JavaScript object. + /// + public readonly struct JsIntersectionObserverOptions + { + public JsIntersectionObserverOptions() + { + Root = null; + Threshold = [0]; + } + + /// + /// The root element used for intersection calculations. If null, the viewport is used. + /// + [JsonPropertyName("root")] + public ElementReference? Root { get; init; } + + /// + /// Margin around the root. Can have values similar to CSS margin property, e.g., "10px 20px". + /// + [JsonPropertyName("rootMargin")] + public string RootMargin { get; init; } = "0px 0px 0px 0px"; + + /// + /// A set of numbers between 0.0 and 1.0 indicating at what percentage of the target's visibility the observer's callback should be executed. + /// Default is a single value of 0, meaning the callback will be executed as soon as even one pixel is visible, and not again until the target is fully out of view again. + /// A value of 1.0 means the callback will be executed when the target is fully visible. A value of [0, 0.5, 1.0] means the callback will be executed when the target is 0%, 50%, and 100% visible. + /// + [JsonPropertyName("threshold")] + public IEnumerable Threshold { get; init; } + } + +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member +} diff --git a/ObserverLibrary/JsModels/ResizeModels.cs b/ObserverLibrary/JsModels/JsResizeObserverEntry.cs similarity index 55% rename from ObserverLibrary/JsModels/ResizeModels.cs rename to ObserverLibrary/JsModels/JsResizeObserverEntry.cs index 44a20bf..4c4ede9 100644 --- a/ObserverLibrary/JsModels/ResizeModels.cs +++ b/ObserverLibrary/JsModels/JsResizeObserverEntry.cs @@ -48,57 +48,5 @@ public class JsResizeObserverEntry [JsonPropertyName("targetTrackingId")] public string? TargetElementTrackingId { get; set; } } - /// - /// Model for ResizeObserverSize javascript models - /// - public struct JsResizeObserverSize { - /// - /// The length of the observed element's content box in the block dimension. - /// - /// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentBoxSize#value - /// - [JsonPropertyName("blockSize")] - public double BlockSize { get; set; } - - /// - /// The length of the observed element's content box in the inline dimension. - /// - /// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentBoxSize#value - /// - [JsonPropertyName("inlineSize")] - public double InlineSize { get; set; } - } - -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - - /// - /// Model for DomRectReadOnly javascript objects - /// - public struct JsDomRect - { - [JsonPropertyName("x")] - public double X { get; set; } - - [JsonPropertyName("y")] - public double Y { get; set; } - - [JsonPropertyName("width")] - public double Width { get; set; } - - [JsonPropertyName("height")] - public double Height { get; set; } - - [JsonPropertyName("top")] - public double Top { get; set; } - - [JsonPropertyName("right")] - public double Right { get; set; } - - [JsonPropertyName("bottom")] - public double Bottom { get; set; } - - [JsonPropertyName("left")] - public double Left { get; set; } - } } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member \ No newline at end of file diff --git a/ObserverLibrary/JsModels/JsResizeObserverSize.cs b/ObserverLibrary/JsModels/JsResizeObserverSize.cs new file mode 100644 index 0000000..bd07c18 --- /dev/null +++ b/ObserverLibrary/JsModels/JsResizeObserverSize.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +namespace BlazorObservers.ObserverLibrary.JsModels +{ + /// + /// Model for ResizeObserverSize JavaScript models + /// + public readonly struct JsResizeObserverSize + { + /// + /// The length of the observed element's content box in the block dimension. + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentBoxSize#value + /// + [JsonPropertyName("blockSize")] + public double BlockSize { get; init; } + + /// + /// The length of the observed element's content box in the inline dimension. + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry/contentBoxSize#value + /// + [JsonPropertyName("inlineSize")] + public double InlineSize { get; init; } + } +} +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member \ No newline at end of file diff --git a/ObserverLibrary/Models/ElementRegistration.cs b/ObserverLibrary/Models/ElementRegistration.cs new file mode 100644 index 0000000..599f84a --- /dev/null +++ b/ObserverLibrary/Models/ElementRegistration.cs @@ -0,0 +1,17 @@ +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; + +namespace BlazorObservers.ObserverLibrary.Models +{ + internal readonly struct ElementRegistration where TTask : ObserverTask + { + public readonly ElementReference ElementReference; + public readonly TTask TaskReference; + + public ElementRegistration(ElementReference elementReference, TTask taskReference) + { + ElementReference = elementReference; + TaskReference = taskReference; + } + } +} diff --git a/ObserverLibrary/ObserverLibrary.csproj b/ObserverLibrary/ObserverLibrary.csproj index 8aeda0c..677d9ed 100644 --- a/ObserverLibrary/ObserverLibrary.csproj +++ b/ObserverLibrary/ObserverLibrary.csproj @@ -1,13 +1,13 @@  - net6.0 + net8.0 enable enable BlazorObservers.ObserverLibrary - 1.0.1 + 2.0.0 Author-e JelleBootsma BlazorObservers @@ -36,13 +36,18 @@ Currently only Resize observer is present.
+ + + <_Parameter1>ObserverLibraryTests + + - +
diff --git a/ObserverLibrary/Services/AbstractObserverService.cs b/ObserverLibrary/Services/AbstractObserverService.cs index e9867eb..684b16e 100644 --- a/ObserverLibrary/Services/AbstractObserverService.cs +++ b/ObserverLibrary/Services/AbstractObserverService.cs @@ -1,4 +1,6 @@ -using BlazorObservers.ObserverLibrary.Tasks; +using System.Collections.Concurrent; +using BlazorObservers.ObserverLibrary.Models; +using BlazorObservers.ObserverLibrary.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; @@ -7,8 +9,12 @@ namespace BlazorObservers.ObserverLibrary.Services /// /// Abstract base for all ObserverRegistractionServices /// - public abstract class AbstractObserverService : IAsyncDisposable + public abstract class AbstractObserverService : IAsyncDisposable where TTask : ObserverTask { + /// + /// Static parser for Guid to avoid allocations + /// + protected static readonly Func _guidParser = Guid.Parse; /// /// Js runtime to use for interop /// @@ -18,6 +24,17 @@ public abstract class AbstractObserverService : IAsyncDisposable /// protected readonly Lazy> _moduleTask; + /// + /// Dictionary to store the registered tasks managed by this service + /// + protected private readonly ConcurrentDictionary _registeredTasks = new(); + + /// + /// Dictionary to store the registered elements managed by this service + /// + protected private readonly ConcurrentDictionary> _registeredElements = new(); + + /// /// Base constructor of ObserverRegistrationServices. /// @@ -44,6 +61,94 @@ public async ValueTask DisposeAsync() GC.SuppressFinalize(this); } + + protected virtual Task ValidateObserverRegistration(Func onObserve, params ElementReference[] targetElements) + { + if (onObserve is null) + throw new ArgumentNullException(nameof(onObserve)); + + if (targetElements is null) + throw new ArgumentNullException(nameof(targetElements)); + + if (targetElements.Length == 0) + throw new ArgumentException("At least 1 element must be observed"); + + return DoObserverRegistration(onObserve, targetElements); + } + + /// + /// Register an async function to execute on trigger of any one of the elements + /// + /// Function to execute on trigger + /// Elements to observe + /// + /// Thrown if any of the arguments is null + /// Thrown if targetElements is an empty array + public Task RegisterObserver(Func onObserve, params ElementReference[] targetElements) + { + return ValidateObserverRegistration(async (entries) => await onObserve(entries), targetElements); + } + + /// + /// Register a synchronous function to execute on trigger of any one of the elements + /// + /// Function to execute on trigger + /// Elements to observe + /// + /// Thrown if any of the arguments is null + /// Thrown if targetElements is an empty array + public Task RegisterObserver(Action onObserve, params ElementReference[] targetElements) + { + if (onObserve is null) + throw new ArgumentNullException(nameof(onObserve)); + + return ValidateObserverRegistration((entries) => { onObserve(entries); return ValueTask.CompletedTask; }, targetElements); + } + + protected abstract Task DoObserverRegistration(Func onObserve, params ElementReference[] targetElements); + + /// + /// Add a specified element to the list of observed elements of a specific task. + /// + /// If the element is not successfully added return false. + /// Otherwise return true. + /// + /// + /// + /// + /// If observerTask is null + public Task StartObserving(TTask observerTask, ElementReference newElement) + { + if (observerTask is null) + throw new ArgumentNullException(nameof(observerTask)); + if (observerTask.ConnectedElements.Any(x => EqualityComparer.Default.Equals(x.Value, newElement))) + return Task.FromResult(true); // Element is already observed + + return DoStartObserving(observerTask, newElement); + } + + + + /// + /// Remove a specified element from the list of observed elements of a specific task. + /// + /// If the element is successfully remove, and was present in the observed elements list, return true. + /// Otherwise, return false. + /// + /// + /// + /// + /// If observerTask is null + public Task StopObserving(TTask observerTask, ElementReference element) + { + if (observerTask is null) + throw new ArgumentNullException(nameof(observerTask)); + if (!observerTask.ConnectedElements.Any(x => EqualityComparer.Default.Equals(x.Value, element))) + return Task.FromResult(false); + + return DoStopObserving(observerTask, element); + } + /// /// Perform actual Disposing /// @@ -56,5 +161,78 @@ protected virtual async ValueTask DisposeAsyncCore() await module.DisposeAsync(); } } + + protected async Task DoStartObserving(TTask observerTask, ElementReference element) + { + var module = await _moduleTask.Value; + var newTrackingIdString = await module.InvokeAsync("ObserverManager.StartObserving", observerTask.TaskId.ToString(), element); + if (newTrackingIdString is null || !Guid.TryParse(newTrackingIdString, out Guid newTrackingId)) return false; + var elRegistration = new ElementRegistration(element, observerTask); + _registeredElements[newTrackingId] = elRegistration; + observerTask.ConnectedElements[newTrackingId] = element; + return true; + } + + + protected async Task DoStopObserving(TTask observerTask, ElementReference element) + { + var module = await _moduleTask.Value; + var successRemovedByJs = await module.InvokeAsync("ObserverManager.StopObserving", observerTask.TaskId.ToString(), element); + + // Remove registration + var toRemove = _registeredElements.Where(x => + EqualityComparer.Default.Equals(x.Value.ElementReference, element) && + x.Value.TaskReference == observerTask); + foreach (var reg in toRemove) + { + _registeredElements.Remove(reg.Key, out _); + } + var toRemoveFromTask = observerTask.ConnectedElements.Where(x => EqualityComparer.Default.Equals(x.Value, element)); + foreach (var connectedElement in toRemoveFromTask) + { + observerTask.ConnectedElements.Remove(connectedElement.Key, out _); + } + return successRemovedByJs; + } + + /// + /// Remove a registered observer using the TaskId + /// + /// Id of the ObserverTask to remove + /// + public async Task DeregisterObserver(Guid id) + { + var module = await _moduleTask.Value; + if (_registeredTasks.Remove(id, out TTask? taskRef)) + { + taskRef?.SelfRef.Dispose(); + taskRef?.ConnectedElements.Clear(); + } + await module.InvokeVoidAsync("ObserverManager.RemoveObserver", id.ToString()); + if (taskRef is not null) + { + var toRemove = _registeredElements.Where(x => x.Value.TaskReference == taskRef); + foreach (var registration in toRemove) + { + _registeredElements.Remove(registration.Key, out _); + } + } + + } + + /// + /// Remove a resize observer using the observerTask reference + /// + /// + /// + /// + public Task DeregisterObserver(TTask observerTask) + { + if (observerTask is null) + throw new ArgumentNullException(nameof(observerTask)); + + + return DeregisterObserver(observerTask.TaskId); + } } } \ No newline at end of file diff --git a/ObserverLibrary/Services/IntersectionObserverService.cs b/ObserverLibrary/Services/IntersectionObserverService.cs new file mode 100644 index 0000000..fd27dcf --- /dev/null +++ b/ObserverLibrary/Services/IntersectionObserverService.cs @@ -0,0 +1,69 @@ +using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Models; +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorObservers.ObserverLibrary.Services +{ + /// + /// Service to manage intersection observer registrations. + /// + /// With this, you can execute dotNET functions when elements intersect the viewport or a parent element. + /// + public class IntersectionObserverService : OptionedObserverService + { + /// + /// Constructor of an IntersectionObserverRegistrationService. + /// + /// Should not be used by user code, but service should be injected using Dependency Injection. + /// + /// + /// + public IntersectionObserverService(IJSRuntime jsRuntime) : base(jsRuntime) + { + } + + protected override Task ValidateObserverRegistration( + Func onObserve, + JsIntersectionObserverOptions options, + params ElementReference[] targetElements) + { + if (options.Threshold?.Any(t => t < 0 || t > 1) ?? false) + throw new ArgumentOutOfRangeException(nameof(options.Threshold), "All threshold values must be between 0 and 1"); + return base.ValidateObserverRegistration(onObserve, options, targetElements); + } + + protected override async Task DoObserverRegistration( + Func onObserve, + JsIntersectionObserverOptions options, + params ElementReference[] targetElements) + { + var module = await _moduleTask.Value; + var task = new IntersectionTask(onObserve, options); + _registeredTasks[task.TaskId] = task; + + + object[] jsParams = [task.TaskId.ToString(), task.SelfRef, options, .. targetElements]; + + var stringIds = await module.InvokeAsync("ObserverManager.CreateNewIntersectionObserver", jsParams); + var uniqueIds = stringIds.Select(_guidParser); // Reuse static delegate to avoid allocation + + var newReferences = uniqueIds.Zip( + targetElements, + (id, el) => new KeyValuePair>( + id, + new ElementRegistration(el, task))); + + foreach (var newRef in newReferences) + { + _registeredElements[newRef.Key] = newRef.Value; + task.ConnectedElements[newRef.Key] = newRef.Value.ElementReference; + } + + return task; + } + + } + +} diff --git a/ObserverLibrary/Services/OptionedObserverService.cs b/ObserverLibrary/Services/OptionedObserverService.cs new file mode 100644 index 0000000..3bb698e --- /dev/null +++ b/ObserverLibrary/Services/OptionedObserverService.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorObservers.ObserverLibrary.Services +{ + public abstract class OptionedObserverService : AbstractObserverService where TTask : ObserverTask where TOptions : new() + { + protected OptionedObserverService(IJSRuntime jsRuntime) : base(jsRuntime) + { + } + + protected virtual Task ValidateObserverRegistration(Func onObserve, TOptions options, params ElementReference[] targetElements) + { + if (onObserve is null) + throw new ArgumentNullException(nameof(onObserve)); + + if (targetElements is null) + throw new ArgumentNullException(nameof(targetElements)); + + if (targetElements.Length == 0) + throw new ArgumentException("At least 1 element must be observed"); + + return DoObserverRegistration(onObserve, options, targetElements); + } + + /// + /// Register an async function to execute on trigger of any one of the elements + /// + /// Function to execute on trigger + /// Options to add to this observer + /// Elements to observe + /// + /// Thrown if any of the arguments is null + /// Thrown if targetElements is an empty array + public Task RegisterObserver(Func onObserve, TOptions options, params ElementReference[] targetElements) + { + return ValidateObserverRegistration(async (entries) => await onObserve(entries), options, targetElements); + } + + /// + /// Register a synchronous function to execute on trigger of any one of the elements + /// + /// Function to execute on trigger + /// Options to add to this observer + /// Elements to observe + /// + /// Thrown if any of the arguments is null + /// Thrown if targetElements is an empty array + public Task RegisterObserver(Action onObserve, TOptions options, params ElementReference[] targetElements) + { + if (onObserve is null) + throw new ArgumentNullException(nameof(onObserve)); + + return ValidateObserverRegistration((entries) => { onObserve(entries); return ValueTask.CompletedTask; }, options, targetElements); + } + + protected abstract Task DoObserverRegistration(Func onObserve, TOptions options, params ElementReference[] targetElements); + + + protected override Task DoObserverRegistration(Func onObserve, params ElementReference[] targetElements) => + DoObserverRegistration(onObserve, new TOptions(), targetElements); + + } +} diff --git a/ObserverLibrary/Services/ResizeObserverService.cs b/ObserverLibrary/Services/ResizeObserverService.cs index 6180a81..3d8308c 100644 --- a/ObserverLibrary/Services/ResizeObserverService.cs +++ b/ObserverLibrary/Services/ResizeObserverService.cs @@ -1,6 +1,8 @@ using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Models; using BlazorObservers.ObserverLibrary.Tasks; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; using Microsoft.JSInterop; using System.Collections.Concurrent; @@ -12,10 +14,8 @@ namespace BlazorObservers.ObserverLibrary.Services /// With this, you can execute dotNET functions on element resize. /// /// - public class ResizeObserverService : AbstractObserverService + public sealed class ResizeObserverService : AbstractObserverService { - private static readonly ConcurrentDictionary _registeredTasks = new(); - private static readonly ConcurrentDictionary _registeredElements = new(); /// /// Constructor of a ResizeObserverRegistrationService. @@ -27,60 +27,22 @@ public ResizeObserverService(IJSRuntime jsRuntime) : base(jsRuntime) { } - /// - /// Register a synchronous function to execute on resize of any one of the elements - /// - /// Function to execute on resize - /// Elements to observe - /// - /// Thrown if any of the arguments is null - /// Thrown if targetElements is an empty array - public Task RegisterObserver(Action onObserve, params ElementReference[] targetElements) - { - return ValidateObserverRegistration((entries) => { onObserve(entries); return ValueTask.CompletedTask; }, targetElements); - } - - /// - /// Register an async function to execute on resize of any one of the elements - /// - /// Function to execute on resize - /// Elements to observe - /// - /// Thrown if any of the arguments is null - /// Thrown if targetElements is an empty array - public Task RegisterObserver(Func onObserve, params ElementReference[] targetElements) - { - return ValidateObserverRegistration(async (entries) => await onObserve(entries), targetElements); - } - - - private Task ValidateObserverRegistration(Func onObserve, params ElementReference[] targetElements) - { - if (onObserve is null) - throw new ArgumentNullException(nameof(onObserve)); - - if (targetElements is null) - throw new ArgumentNullException(nameof(targetElements)); - - - if (targetElements.Length == 0) - throw new ArgumentException("At least 1 element must be observed"); - return DoObserverRegistration(onObserve, targetElements); - } - - private async Task DoObserverRegistration(Func onObserve, params ElementReference[] targetElements) + protected override async Task DoObserverRegistration(Func onObserve, params ElementReference[] targetElements) { var module = await _moduleTask.Value; var task = new ResizeTask(onObserve); _registeredTasks[task.TaskId] = task; - var JsParams = new List { task.TaskId.ToString(), task.SelfRef }; - JsParams.AddRange(targetElements.Cast()); + object[] jsParams = [task.TaskId.ToString(), task.SelfRef, .. targetElements]; - var stringIds = await module.InvokeAsync("ObserverManager.CreateNewResizeObserver", JsParams.ToArray()); - var uniqueIds = stringIds.Select(x => Guid.Parse(x)); + var stringIds = await module.InvokeAsync("ObserverManager.CreateNewResizeObserver", jsParams); + var uniqueIds = stringIds.Select(_guidParser); - var newReferences = uniqueIds.Zip(targetElements, (id, el) => new KeyValuePair(id, new ElementRegistration(el, task))); + var newReferences = uniqueIds.Zip( + targetElements, + (id, el) => new KeyValuePair>( + id, + new ElementRegistration(el, task))); foreach (var newRef in newReferences) { @@ -90,120 +52,6 @@ private async Task DoObserverRegistration(Func - /// Add a specified element to the list of observed elements of a specific task. - /// - /// If the element is not successfully added return false. - /// Otherwise return true. - /// - /// - /// - /// - /// If observerTask is null - public Task StartObserving(ResizeTask observerTask, ElementReference newElement) - { - if (observerTask is null) - throw new ArgumentNullException(nameof(observerTask)); - if (observerTask.ConnectedElements.Any(x => x.Value.Equals(newElement))) - return Task.FromResult(true); // Element is already observed - - return DoStartObserving(observerTask, newElement); - } - - private async Task DoStartObserving(ResizeTask observerTask, ElementReference element) - { - var module = await _moduleTask.Value; - var newTrackingIdString = await module.InvokeAsync("ObserverManager.StartObserving", observerTask.TaskId.ToString(), element); - if (newTrackingIdString is null || !Guid.TryParse(newTrackingIdString, out Guid newTrackingId)) return false; - var elRegistration = new ElementRegistration(element, observerTask); - _registeredElements[newTrackingId] = elRegistration; - observerTask.ConnectedElements[newTrackingId] = element; - return true; - } - - - - /// - /// Remove a specified element from the list of observed elements of a specific task. - /// - /// If the element is successfully remove, and was present in the observed elements list, return true. - /// Otherwise, return false. - /// - /// - /// - /// - /// If observerTask is null - public Task StopObserving(ResizeTask observerTask, ElementReference element) - { - if (observerTask is null) - throw new ArgumentNullException(nameof(observerTask)); - if (!observerTask.ConnectedElements.Any(x => x.Value.Equals(element))) - return Task.FromResult(false); - - return DoStopObserving(observerTask, element); - } - - - private async Task DoStopObserving(ResizeTask observerTask, ElementReference element) - { - var module = await _moduleTask.Value; - var successRemovedByJs = await module.InvokeAsync("ObserverManager.StopObserving", observerTask.TaskId.ToString(), element); - - // Remove registration - var toRemove = _registeredElements.Where(x => x.Value.ElementReference.Equals(element) && x.Value.TaskReference == observerTask); - foreach (var reg in toRemove) - { - _registeredElements.Remove(reg.Key, out _); - } - var toRemoveFromTask = observerTask.ConnectedElements.Where(x => x.Value.Equals(element)); - foreach (var connectedElement in toRemoveFromTask) - { - observerTask.ConnectedElements.Remove(connectedElement.Key, out _); - } - return successRemovedByJs; - } - - /// - /// Remove a resize observer using the observerTask reference - /// - /// - /// - /// - public Task DeregisterObserver(ResizeTask observerTask) - { - if (observerTask is null) - throw new ArgumentNullException(nameof(observerTask)); - - - return DeregisterObserver(observerTask.TaskId); - } - - /// - /// Remove a resize observer using the TaskId - /// - /// Id of the ObserverTask to remove - /// - public async Task DeregisterObserver(Guid id) - { - var module = await _moduleTask.Value; - if (_registeredTasks.Remove(id, out ResizeTask? taskRef)) - { - taskRef?.SelfRef.Dispose(); - taskRef?.ConnectedElements.Clear(); - } - await module.InvokeVoidAsync("ObserverManager.RemoveResizeObserver", id.ToString()); - if (taskRef is not null) - { - var toRemove = _registeredElements.Where(x => x.Value.TaskReference == taskRef); - foreach (var registration in toRemove) - { - _registeredElements.Remove(registration.Key, out _); - } - } - - } - } - internal record ElementRegistration(ElementReference ElementReference, ResizeTask TaskReference); + } diff --git a/ObserverLibrary/Tasks/IntersectionTask.cs b/ObserverLibrary/Tasks/IntersectionTask.cs new file mode 100644 index 0000000..f421dad --- /dev/null +++ b/ObserverLibrary/Tasks/IntersectionTask.cs @@ -0,0 +1,39 @@ +using BlazorObservers.ObserverLibrary.JsModels; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorObservers.ObserverLibrary.Tasks +{ + /// + /// Handle for a function which is executed when an element's intersection state changes. + /// + /// Should not be created by user code. Instead the IntersectionObserverService should be used. + /// + public class IntersectionTask : OptionedTask + { + internal IntersectionTask(Func taskFunc, JsIntersectionObserverOptions options) : base(taskFunc, options) + { + } + + /// + /// Method to execute when an element's intersection changes. + /// + /// Should not be called by user code. + /// + /// Array of intersection entries from JavaScript + /// + [JSInvokable("Execute")] + public override ValueTask Execute(JsIntersectionObserverEntry[] jsData) + { + foreach (var dataElement in jsData) + { + if (Guid.TryParse(dataElement.TargetElementTrackingId, out Guid trackingId) && + ConnectedElements.TryGetValue(trackingId, out var element)) + { + dataElement.TargetElement = element; + } + } + return base.Execute(jsData); + } + } +} diff --git a/ObserverLibrary/Tasks/ObserverTask.cs b/ObserverLibrary/Tasks/ObserverTask.cs index 8934c0b..0a18444 100644 --- a/ObserverLibrary/Tasks/ObserverTask.cs +++ b/ObserverLibrary/Tasks/ObserverTask.cs @@ -1,12 +1,13 @@ -using Microsoft.JSInterop; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; namespace BlazorObservers.ObserverLibrary.Tasks { /// /// Abstract base class for handles of C# task, which is executed after observer trigger. /// - /// - public abstract class ObserverTask + /// The type of data provided to callback delegate on observer trigger. + public abstract class ObserverTask { private bool _paused = false; private bool _delayTriggering = false; @@ -19,6 +20,7 @@ public abstract class ObserverTask /// Unique identifier of this Task /// public Guid TaskId { get; } = Guid.NewGuid(); + internal Dictionary ConnectedElements { get; } = new Dictionary(); private protected ObserverTask(Func taskFunc) { @@ -38,7 +40,7 @@ private protected ObserverTask(Func taskFunc) /// public void OnlyTriggerLast(int delay) { - if (delay < 0) throw new ArgumentException("Delay can not be negative"); + if (delay < 0) throw new ArgumentException($"{nameof(delay)} must be positive"); _paused = false; _delay = delay; _delayTriggering = true; @@ -81,13 +83,13 @@ public virtual async ValueTask Execute(T jsData) } _executionCount++; ulong runNumber = _executionCount; - + // Wait for the delay await Task.Delay(_delay); // Check if this execution is still the latest run if (runNumber < _executionCount) return; - + // Execute await _taskFunc(jsData); } diff --git a/ObserverLibrary/Tasks/OptionedTask.cs b/ObserverLibrary/Tasks/OptionedTask.cs new file mode 100644 index 0000000..ac8edb8 --- /dev/null +++ b/ObserverLibrary/Tasks/OptionedTask.cs @@ -0,0 +1,25 @@ +namespace BlazorObservers.ObserverLibrary.Tasks +{ + /// + /// Represents a task that includes additional options for configuration. + /// + /// The type of data provided to callback delegate on observer trigger. + /// The type of options used to configure the task. + public abstract class OptionedTask : ObserverTask + { + /// + /// Gets the options used to configure the task. + /// + public TOptions Options { get; init; } + + /// + /// Initializes a new instance of the class. + /// + /// The function to execute as the task. + /// The options used to configure the task. + private protected OptionedTask(Func taskFunc, TOptions options) : base(taskFunc) + { + Options = options; + } + } +} diff --git a/ObserverLibrary/Tasks/ResizeTask.cs b/ObserverLibrary/Tasks/ResizeTask.cs index 3ffb1b1..16f8dc6 100644 --- a/ObserverLibrary/Tasks/ResizeTask.cs +++ b/ObserverLibrary/Tasks/ResizeTask.cs @@ -11,10 +11,8 @@ namespace BlazorObservers.ObserverLibrary.Tasks /// public class ResizeTask : ObserverTask { - internal Dictionary ConnectedElements { get; set; } internal ResizeTask(Func taskFunc) : base(taskFunc) { - ConnectedElements = new Dictionary(); } /// diff --git a/ObserverLibrary/wwwroot/ObserverManager.js b/ObserverLibrary/wwwroot/ObserverManager.js index c17c4fb..566f600 100644 --- a/ObserverLibrary/wwwroot/ObserverManager.js +++ b/ObserverLibrary/wwwroot/ObserverManager.js @@ -1,6 +1,3 @@ -// This is a JavaScript module that is loaded on demand. It can export any number of -// functions, and may import other JavaScript modules if required. - export class ObserverManager { @@ -17,7 +14,7 @@ export class ObserverManager { ); } - static ActiveResizeObservers = {}; + static ActiveObservers = {}; /** * Register a resize observer @@ -27,34 +24,73 @@ export class ObserverManager { * @returns {string[]} Generated ids for the tracked elements in the same order as the given elements */ static CreateNewResizeObserver(id, dotNetRef, ...els) { - let callback = (entries, obsCallback) => { - let dotNetArguments = []; - for (let entry of entries) { - dotNetArguments.push(this.#CreateDotNetCallbackObject(entry, id)) + const callback = (entries, obsCallback) => { + const dotNetArguments = []; + for (const entry of entries) { + dotNetArguments.push(this.#CreateResizeCallbackObject(entry, id)) } dotNetRef.invokeMethodAsync("Execute", dotNetArguments); } - let obs = new ResizeObserver(callback) - let ids = []; + const obs = new ResizeObserver(callback) + const ids = []; + + for (const el of els) { + const elementTrackingId = this.#GetGuid(); + el.setAttribute(this.#GetObserverTrackingAttributeName(id), elementTrackingId); + ids.push(elementTrackingId); + obs.observe(el); + } + this.ActiveObservers[id] = obs; + return ids; + } - for (let el of els) { - let elementTrackingId = this.#GetGuid(); - el.setAttribute(this.#GetResizeTrackingAttributeName(id), elementTrackingId); + /** + * Register an intersection observer + * @param {string} id + * @param {object} dotNetRef + * @param {IntersectionObserverInit} options + * @param {...Element} els + * @returns {string[]} Generated ids for the tracked elements in the same order as the given elements + */ + static CreateNewIntersectionObserver(id, dotNetRef, options, ...els) { + const callback = (entries, obsCallback) => { + const dotNetArguments = []; + for (const entry of entries) { + dotNetArguments.push(this.#CreateIntersectionCallbackObject(entry, id)) + } + dotNetRef.invokeMethodAsync("Execute", dotNetArguments); + } + const obs = new IntersectionObserver(callback, options) + const ids = []; + for (const el of els) { + const elementTrackingId = this.#GetGuid(); + el.setAttribute(this.#GetObserverTrackingAttributeName(id), elementTrackingId); ids.push(elementTrackingId); obs.observe(el); } - this.ActiveResizeObservers[id] = obs; + this.ActiveObservers[id] = obs; return ids; } + + /** + * (Deprecated) Disconnect and delete a existing observer + * Exists for backwards compatibility with the old API to avoid cache mismatch issues + * @param {string} observerId + * @returns + */ + static RemoveResizeObserver(observerId) { + return this.RemoveObserver(observerId); + } + /** - * Disconnect and delete a resize observer + * Disconnect and delete a existing observer * @param {string} observerId */ - static RemoveResizeObserver(observerId) { - if (!this.ActiveResizeObservers[observerId]) return; - this.ActiveResizeObservers[observerId].disconnect(); - delete this.ActiveResizeObservers[observerId]; + static RemoveObserver(observerId) { + if (!this.ActiveObservers[observerId]) return; + this.ActiveObservers[observerId].disconnect(); + delete this.ActiveObservers[observerId]; } /** @@ -64,10 +100,10 @@ export class ObserverManager { */ static StartObserving(observerId, element) { - if (!this.ActiveResizeObservers[observerId]) return null; - let obs = this.ActiveResizeObservers[observerId]; + if (!this.ActiveObservers[observerId]) return null; + let obs = this.ActiveObservers[observerId]; let elementTrackingId = this.#GetGuid(); - element.setAttribute(this.#GetResizeTrackingAttributeName(observerId), elementTrackingId); + element.setAttribute(this.#GetObserverTrackingAttributeName(observerId), elementTrackingId); obs.observe(element); return elementTrackingId; } @@ -78,36 +114,56 @@ export class ObserverManager { * @param {Element} element */ static StopObserving(observerId, element) { - if (!this.ActiveResizeObservers[observerId]) return false; - let obs = this.ActiveResizeObservers[observerId]; + if (!this.ActiveObservers[observerId]) return false; + let obs = this.ActiveObservers[observerId]; obs.unobserve(element); - element.removeAttribute(this.#GetResizeTrackingAttributeName(observerId)); + element.removeAttribute(this.#GetObserverTrackingAttributeName(observerId)); return true; } /** - * Generate serializable object for DotNET + * Generate serializable resize object for DotNET * @param {ResizeObserverEntry} callbackEl * @param {string} observerId * @returns {object} Serialize object with all required info for dotNet */ - static #CreateDotNetCallbackObject(callbackEl, observerId) { + static #CreateResizeCallbackObject(callbackEl, observerId) { let result = {}; result.borderBoxSize = this.#ConvertResizeObserverSizeObject(callbackEl.borderBoxSize[0]); result.contentBoxSize = this.#ConvertResizeObserverSizeObject(callbackEl.contentBoxSize[0]); result.contentRect = callbackEl.contentRect; - result.targetTrackingId = callbackEl.target.getAttribute(this.#GetResizeTrackingAttributeName(observerId)); + result.targetTrackingId = callbackEl.target.getAttribute(this.#GetObserverTrackingAttributeName(observerId)); return result; } + /** + * Generate serializable intersection object for DotNET + * @param {IntersectionObserverEntry} callbackEl + * @param {string} observerId + * @returns {object} Serialize object with all required info for dotNet + */ + static #CreateIntersectionCallbackObject(callbackEl, observerId) { + return { + time: callbackEl.time, + rootBounds: callbackEl.rootBounds || null, + boundingClientRect: callbackEl.boundingClientRect, + intersectionRect: callbackEl.intersectionRect, + isIntersecting: callbackEl.isIntersecting, + intersectionRatio: callbackEl.intersectionRatio, + targetTrackingId: callbackEl.target.getAttribute(this.#GetObserverTrackingAttributeName(observerId)) + }; + } + /** * Get the attribute name used to track elements belonging to a specific observer * @param {string} observerId */ - static #GetResizeTrackingAttributeName(observerId) { - return `ResizeElementTrackingId-${observerId}`; + static #GetObserverTrackingAttributeName(observerId) { + return `ObserverElementTrackingId-${observerId}`; } + + /** * Convert a ResizeObserverSize object to a serializable object * @param {ResizeObserverSize} [input] diff --git a/ObserverLibraryTests/ObserverLibraryTests.csproj b/ObserverLibraryTests/ObserverLibraryTests.csproj new file mode 100644 index 0000000..3a2b2a4 --- /dev/null +++ b/ObserverLibraryTests/ObserverLibraryTests.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + + + diff --git a/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs b/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs new file mode 100644 index 0000000..d91c47f --- /dev/null +++ b/ObserverLibraryTests/Services/ResizeObserverServiceTests.cs @@ -0,0 +1,220 @@ +using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using Moq; +using NUnit.Framework; + +namespace BlazorObservers.ObserverLibrary.Tests.Services +{ + [TestFixture] + public class ResizeObserverServiceTests + { + private Mock _jsRuntimeMock = null!; + private Mock _jsModuleMock = null!; + private ResizeObserverService _service = null!; + + private readonly Dictionary _elementIdMap = new(); + + [SetUp] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", Justification = "Mock Setup")] + public void SetUp() + { + _elementIdMap.Clear(); + + _jsModuleMock = new Mock(); + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.IsAny())) + .Returns((string _, object[] args) => + { + var elements = args.Skip(2).Cast().ToArray(); + + List ids = new List(elements.Length); + foreach (var element in elements) + { + if (!_elementIdMap.TryGetValue(element, out var id)) + { + id = Guid.NewGuid().ToString(); + _elementIdMap[element] = id; + ids.Add(id); + } + } + + return new ValueTask(Task.FromResult(ids.ToArray())); + }); + + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.StartObserving", + It.IsAny())) + .Returns((string _, object[] args) => + { + var element = (ElementReference)args[1]; + + if (!_elementIdMap.TryGetValue(element, out var id)) + { + id = Guid.NewGuid().ToString(); + _elementIdMap[element] = id; + } + + return new ValueTask(Task.FromResult(id)); + }); + + _jsModuleMock + .Setup(m => m.InvokeAsync( + "ObserverManager.StopObserving", + It.IsAny())) + .Returns((string _, object[] args) => + { + var element = (ElementReference)args[1]; + return new ValueTask(Task.FromResult(_elementIdMap.Remove(element))); + }); + + _jsRuntimeMock = new Mock(); + _jsRuntimeMock + .Setup(r => r.InvokeAsync( + "import", + It.Is(args => args.Length == 1 && args[0]!.ToString() == "./_content/BlazorObservers/ObserverManager.js"))) + .Returns(new ValueTask(Task.FromResult(_jsModuleMock.Object))); + + _service = new ResizeObserverService(_jsRuntimeMock.Object); + } + + [Test] + public async Task RegisterObserver_SynchronousFunction_ValidatesAndRegisters() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + + var task = await _service.RegisterObserver(onObserve, element); + + Assert.That(task, Is.Not.Null); + Assert.That(task.ConnectedElements.Count, Is.EqualTo(1)); + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.Is(args => args.Contains(element))), + Times.Once); + } + + [Test] + public async Task RegisterObserver_AsyncFunction_ValidatesAndRegisters() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Func(entries => Task.CompletedTask); + + var task = await _service.RegisterObserver(onObserve, element); + + Assert.That(task, Is.Not.Null); + Assert.That(task.ConnectedElements.Count, Is.EqualTo(1)); + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.CreateNewResizeObserver", + It.Is(args => args.Contains(element))), + Times.Once); + } + + [Test] + public async Task StartObserving_ValidElement_AddsToObservedElements() + { + var element1 = new ElementReference(Guid.NewGuid().ToString()); + var element2 = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + var task = await _service.RegisterObserver(onObserve, element1); + + var result = await _service.StartObserving(task, element2); + + Assert.That(result, Is.True); + Assert.That(task.ConnectedElements.Count, Is.EqualTo(2)); + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.StartObserving", + It.Is(args => + args.Length == 2 && + args[0]!.ToString() == task.TaskId.ToString() && + args[1].GetType() == typeof(ElementReference) && ((ElementReference)args[1]).Equals(element2))), + Times.Once); + } + + [Test] + public async Task StopObserving_ValidElement_RemovesFromObservedElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var onObserve = new Action(entries => { }); + var task = await _service.RegisterObserver(onObserve, element); + + var result = await _service.StopObserving(task, element); + + Assert.That(result, Is.True); + Assert.That(task.ConnectedElements.Count, Is.EqualTo(0)); + + _jsModuleMock.Verify(m => + m.InvokeAsync( + "ObserverManager.StopObserving", + It.Is(args => + args.Length == 2 && + args[0]!.ToString() == task.TaskId.ToString() && + args[1].GetType() == typeof(ElementReference) && element.Equals((ElementReference)args[1]))), + Times.Once); + } + + [Test] + public async Task DeregisterObserver_RemovesObserverAndElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var task = await _service.RegisterObserver(entries => { }, element); + + await _service.DeregisterObserver(task); + + Assert.That(task.ConnectedElements.Count, Is.EqualTo(0)); + } + + [Test] + public async Task DeregisterObserver_ById_RemovesObserverAndElements() + { + var element = new ElementReference(Guid.NewGuid().ToString()); + var task = await _service.RegisterObserver(entries => { }, element); + + await _service.DeregisterObserver(task.TaskId); + + Assert.That(task.ConnectedElements.Count, Is.EqualTo(0)); + } + + [Test] + public void RegisterObserver_ThrowsOnNullAction() + { + Assert.That( + async () => await _service.RegisterObserver((Action)null!, new ElementReference()), + Throws.TypeOf()); + } + + [Test] + public void RegisterObserver_ThrowsOnNullElement() + { + Assert.That( + async () => await _service.RegisterObserver(entries => Task.CompletedTask, null!), + Throws.TypeOf()); + } + + [Test] + public void StartObserving_ThrowsOnNullTask() + { + Assert.That( + async () => await _service.StartObserving(null!, new ElementReference()), + Throws.TypeOf()); + } + + [Test] + public void StopObserving_ThrowsOnNullTask() + { + Assert.That( + async () => await _service.StopObserving(null!, new ElementReference()), + Throws.TypeOf()); + } + } +} diff --git a/ObserverLibraryTests/Tasks/ResizeTaskTests.cs b/ObserverLibraryTests/Tasks/ResizeTaskTests.cs new file mode 100644 index 0000000..3bbac15 --- /dev/null +++ b/ObserverLibraryTests/Tasks/ResizeTaskTests.cs @@ -0,0 +1,127 @@ +using BlazorObservers.ObserverLibrary.JsModels; +using BlazorObservers.ObserverLibrary.Tasks; +using Microsoft.AspNetCore.Components; +using NUnit.Framework; + +namespace BlazorObservers.ObserverLibrary.Tests.Tasks +{ + [TestFixture] + public class ResizeTaskTests + { + private List _executedData; + private ResizeTask _resizeTask; + + [SetUp] + public void SetUp() + { + _executedData = new List(); + _resizeTask = new ResizeTask(entries => + { + _executedData.Add(entries); + return ValueTask.CompletedTask; + }); + } + + [Test] + public async Task Execute_WithValidTrackingId_SetsTargetElement() + { + // Arrange + var element = new ElementReference(Guid.NewGuid().ToString()); + var trackingId = Guid.NewGuid(); + _resizeTask.ConnectedElements[trackingId] = element; + + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = trackingId.ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.That(_executedData.Count, Is.EqualTo(1)); + Assert.That(entries[0].TargetElement, Is.EqualTo(element)); + } + + [Test] + public async Task Execute_WithInvalidTrackingId_DoesNotSetTargetElement() + { + // Arrange + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = "invalid-guid" } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.That(_executedData.Count, Is.EqualTo(1)); + Assert.That(entries[0].TargetElement, Is.Null); + } + + [Test] + public async Task Execute_WhenPaused_DoesNotExecute() + { + // Arrange + _resizeTask.HaltTaskTriggering(); + + var entries = new[] + { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.That(_executedData, Is.Empty); + } + + [Test] + public async Task Execute_WhenOnlyTriggerLast_DebouncesExecution() + { + // Arrange + _resizeTask.OnlyTriggerLast(100); // 100 ms delay + + var entries1 = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + var entries2 = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + var valueTask1 = _resizeTask.Execute(entries1); + var valueTask2 = _resizeTask.Execute(entries2); + + await Task.Delay(200); // Wait long enough to trigger debounce + + await valueTask1; + await valueTask2; + + // Assert + Assert.That(_executedData.Count, Is.EqualTo(1)); + Assert.That(_executedData[0], Is.EqualTo(entries2)); + } + + [Test] + public async Task ResumeTaskTriggering_AllowsExecution() + { + // Arrange + _resizeTask.HaltTaskTriggering(); + _resizeTask.ResumeTaskTriggering(); + + var entries = new[] { + new JsResizeObserverEntry { TargetElementTrackingId = Guid.NewGuid().ToString() } + }; + + // Act + await _resizeTask.Execute(entries); + + // Assert + Assert.That(_executedData.Count, Is.EqualTo(1)); + } + } +}