From ee041c46bc44c654f88282000a146dce1b02e054 Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 13 Jun 2026 12:58:54 +0330 Subject: [PATCH 01/24] add Bmotion tool #12447 --- src/Bmotion/Bit.Bmotion.Demos/App.razor | 12 + .../Bit.Bmotion.Demos.csproj | 26 + .../Bit.Bmotion.Demos/Layout/MainLayout.razor | 19 + .../Pages/AnimatePresencePage.razor | 65 ++ .../Pages/BasicAnimations.razor | 57 ++ .../Bit.Bmotion.Demos/Pages/DragPage.razor | 65 ++ .../Bit.Bmotion.Demos/Pages/Gestures.razor | 79 +++ .../Bit.Bmotion.Demos/Pages/Home.razor | 64 ++ .../Bit.Bmotion.Demos/Pages/Keyframes.razor | 75 +++ .../Bit.Bmotion.Demos/Pages/LayoutPage.razor | 54 ++ .../Pages/ScrollAnimations.razor | 64 ++ .../Bit.Bmotion.Demos/Pages/Springs.razor | 105 +++ .../Bit.Bmotion.Demos/Pages/Variants.razor | 83 +++ src/Bmotion/Bit.Bmotion.Demos/Program.cs | 15 + .../Properties/launchSettings.json | 24 + src/Bmotion/Bit.Bmotion.Demos/_Imports.razor | 13 + .../Bit.Bmotion.Demos/wwwroot/css/app.css | 77 +++ .../wwwroot/css/motion-samples.css | 171 +++++ .../Bit.Bmotion.Demos/wwwroot/index.html | 31 + src/Bmotion/Bit.Bmotion.slnx | 16 + src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj | 34 + src/Bmotion/Bit.Bmotion/BitBmotion.cs | 40 ++ .../Components/AnimatePresence.razor | 11 + .../Components/AnimatePresence.razor.cs | 77 +++ src/Bmotion/Bit.Bmotion/Components/Motion.cs | 618 ++++++++++++++++++ .../Bit.Bmotion/Components/MotionConfig.razor | 38 ++ .../Context/MotionConfigContext.cs | 24 + .../Bit.Bmotion/Context/PresenceContext.cs | 33 + .../Bit.Bmotion/Context/VariantContext.cs | 36 + .../Bit.Bmotion/Engine/AnimationEngine.cs | 331 ++++++++++ .../Bit.Bmotion/Engine/ColorInterpolator.cs | 97 +++ .../Bit.Bmotion/Engine/ColorTweenDriver.cs | 66 ++ .../Bit.Bmotion/Engine/EasingFunctions.cs | 81 +++ .../Engine/ElementAnimationState.cs | 393 +++++++++++ .../Bit.Bmotion/Engine/IAnimationDriver.cs | 20 + .../Bit.Bmotion/Engine/InertiaDriver.cs | 67 ++ .../Bit.Bmotion/Engine/KeyframesDriver.cs | 158 +++++ .../Bit.Bmotion/Engine/SpringDriver.cs | 116 ++++ .../Bit.Bmotion/Engine/TransformComposer.cs | 60 ++ src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs | 67 ++ .../Bit.Bmotion/Interop/MotionInterop.cs | 155 +++++ .../Bit.Bmotion/Models/AnimationProps.cs | 176 +++++ .../Bit.Bmotion/Models/AnimationTarget.cs | 31 + src/Bmotion/Bit.Bmotion/Models/DragOptions.cs | 97 +++ .../Bit.Bmotion/Models/LayoutOptions.cs | 25 + .../Bit.Bmotion/Models/MotionVariants.cs | 33 + src/Bmotion/Bit.Bmotion/Models/PanInfo.cs | 27 + src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs | 36 + .../Bit.Bmotion/Models/TransitionConfig.cs | 274 ++++++++ .../Bit.Bmotion/Models/ViewportOptions.cs | 50 ++ .../Services/AnimationController.cs | 49 ++ .../Bit.Bmotion/Services/AnimationControls.cs | 48 ++ .../Services/MotionAnimateService.cs | 104 +++ .../Bit.Bmotion/Services/MotionValue.cs | 104 +++ .../Bit.Bmotion/Services/ScrollTracker.cs | 87 +++ src/Bmotion/Bit.Bmotion/_Imports.razor | 6 + src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js | 479 ++++++++++++++ src/Bmotion/README.md | 385 +++++++++++ .../Bit.Bmotion.Tests.csproj | 24 + .../Engine/ColorInterpolatorTests.cs | 96 +++ .../Engine/ColorTweenDriverTests.cs | 149 +++++ .../Engine/EasingFunctionsTests.cs | 142 ++++ .../Engine/InertiaDriverTests.cs | 181 +++++ .../Engine/KeyframesDriverTests.cs | 240 +++++++ .../Engine/SpringDriverTests.cs | 265 ++++++++ .../Engine/TransformComposerTests.cs | 182 ++++++ .../Engine/TweenDriverTests.cs | 173 +++++ .../Tests/Bit.Bmotion.Tests/GlobalUsings.cs | 1 + .../Models/TransitionConfigTests.cs | 261 ++++++++ 69 files changed, 7362 insertions(+) create mode 100644 src/Bmotion/Bit.Bmotion.Demos/App.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Bit.Bmotion.Demos.csproj create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/AnimatePresencePage.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/BasicAnimations.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/Gestures.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/Home.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Program.cs create mode 100644 src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json create mode 100644 src/Bmotion/Bit.Bmotion.Demos/_Imports.razor create mode 100644 src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css create mode 100644 src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css create mode 100644 src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html create mode 100644 src/Bmotion/Bit.Bmotion.slnx create mode 100644 src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj create mode 100644 src/Bmotion/Bit.Bmotion/BitBmotion.cs create mode 100644 src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor create mode 100644 src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs create mode 100644 src/Bmotion/Bit.Bmotion/Components/Motion.cs create mode 100644 src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor create mode 100644 src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs create mode 100644 src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs create mode 100644 src/Bmotion/Bit.Bmotion/Context/VariantContext.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/TransformComposer.cs create mode 100644 src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs create mode 100644 src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/DragOptions.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/LayoutOptions.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/PanInfo.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs create mode 100644 src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs create mode 100644 src/Bmotion/Bit.Bmotion/Services/AnimationController.cs create mode 100644 src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs create mode 100644 src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs create mode 100644 src/Bmotion/Bit.Bmotion/Services/MotionValue.cs create mode 100644 src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs create mode 100644 src/Bmotion/Bit.Bmotion/_Imports.razor create mode 100644 src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js create mode 100644 src/Bmotion/README.md create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Bit.Bmotion.Tests.csproj create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorInterpolatorTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorTweenDriverTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/EasingFunctionsTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/InertiaDriverTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/KeyframesDriverTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/SpringDriverTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TransformComposerTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/GlobalUsings.cs create mode 100644 src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs diff --git a/src/Bmotion/Bit.Bmotion.Demos/App.razor b/src/Bmotion/Bit.Bmotion.Demos/App.razor new file mode 100644 index 0000000000..36912bb8ae --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/App.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Bit.Bmotion.Demos.csproj b/src/Bmotion/Bit.Bmotion.Demos/Bit.Bmotion.Demos.csproj new file mode 100644 index 0000000000..4c529996b3 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Bit.Bmotion.Demos.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + + false + false + + + + + + + + + + + + diff --git a/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor b/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor new file mode 100644 index 0000000000..812208ff0c --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor @@ -0,0 +1,19 @@ +@inherits LayoutComponentBase + +
+ + Home + Basics + Springs + Gestures + Variants + Keyframes + AnimatePresence + Drag + Scroll + Layout +
+ +
+ @Body +
diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/AnimatePresencePage.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/AnimatePresencePage.razor new file mode 100644 index 0000000000..32e2e800b5 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/AnimatePresencePage.razor @@ -0,0 +1,65 @@ +@page "/presence" + +
+

AnimatePresence

+

+ Wrap conditional content in AnimatePresence. When + IsPresent becomes false the children play their Exit + animation before being removed from the DOM. +

+ +
+
+ + + +
+
+ + +
+ +
+

List Items

+

Add and remove items from a list with staggered enter/exit animations.

+ +
+
+ @foreach (var item in _items) + { + + + @item.Label + + + + } +
+
+ + +
+ +@code { + private bool _visible = true; + private int _nextId = 4; + + private record Item(int Id, string Label); + private List _items = [new(1,"Item One"), new(2,"Item Two"), new(3,"Item Three")]; + + void AddItem() => _items.Add(new(_nextId++, $"Item {_nextId - 1}")); + void RemoveItem(int id) => _items.RemoveAll(i => i.Id == id); +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/BasicAnimations.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/BasicAnimations.razor new file mode 100644 index 0000000000..fe61cf369b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/BasicAnimations.razor @@ -0,0 +1,57 @@ +@page "/basic" + +
+

Basic Animations

+

+ Use Initial to set the starting state and Animate + for the target. Each property transitions automatically on mount and whenever + Animate changes. +

+ +
+
+ +
+
+ +
+
+ + +
+ +
+

CSS Property Animations

+

Animate any CSS property — colors, border-radius, box-shadow, and more.

+
+
+ +
+
+ +
+ +@code { + private bool _toggled; + private bool _rounded; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor new file mode 100644 index 0000000000..7f85cfb917 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor @@ -0,0 +1,65 @@ +@page "/drag" + +
+

Drag

+

+ Enable drag on any axis with Drag="true". + Add DragOptions to constrain movement, tune elasticity, and momentum. +

+ +
+
+ +
+ +
+

+ X-axis only + constraints +

+ +
+
+
+ +
+

Drag Events

+

Listen to OnDragStart, OnDrag, OnDragEnd for real-time position feedback.

+
+
+ +
+ @_dragInfo +
+
+
+
+ +@code { + private string _dragInfo = "Drag the box"; + + void HandleDrag() => _dragInfo = "Dragging…"; + void HandleDragEnd() => _dragInfo = "Released"; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Gestures.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Gestures.razor new file mode 100644 index 0000000000..57078c7e2b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Gestures.razor @@ -0,0 +1,79 @@ +@page "/gestures" + +
+

Hover & Tap

+

+ WhileHover and WhileTap overlay animation states + on top of the base Animate state. They automatically revert on pointer-up / leave. +

+
+
+ +
+ +
+ + Animated Button + +
+
+
+ +
+

Focus

+

WhileFocus plays while an element or its descendants are focused.

+
+
+ +
+
+
+ +
+

Events

+

Listen to gesture callbacks: OnHoverStart, OnTap, OnTapCancel, etc.

+
+
+ +
+ @foreach (var e in _events.TakeLast(8).Reverse()) + { +
@e
+ } +
+
+
+
+ +@code { + private readonly List _events = new(); + + void AddEvent(string name) + { + _events.Add($"[{DateTime.Now:HH:mm:ss.ff}] {name}"); + StateHasChanged(); + } +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Home.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Home.razor new file mode 100644 index 0000000000..a730144008 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Home.razor @@ -0,0 +1,64 @@ +@page "/" + +
+ + Bmotion + + + + A Blazor-native animation library inspired by Framer Motion. + Springs, gestures, layout animations, variants — zero external dependencies. + + + + @foreach (var f in _features) + { + @f + } + +
+ +
+

Quick start

+
+
+ +
+ +
@_quickStart
+
+
+ +@code { + private readonly string[] _features = + [ + "Spring physics", "Tween", "Inertia", "Keyframes", + "Gestures", "Drag", "AnimatePresence", "Variants", + "whileInView", "Layout FLIP", "MotionValue", "Scroll tracking", + ".NET 8+", "Zero JS deps" + ]; + + private const string _quickStart = + ""; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor new file mode 100644 index 0000000000..892a97c693 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor @@ -0,0 +1,75 @@ +@page "/keyframes" + +
+

Keyframes

+

+ Pass an array of values in the Keyframes dictionary of AnimationProps + to define a multi-step animation. Use Transition.Times for custom offsets (0–1). +

+ +
+
+ +
+ +
+ +
+
+
+ +
+

Color Keyframes

+

Smoothly cycle through a palette of colors.

+
+
+ +
+
+
+ +@code { } diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor new file mode 100644 index 0000000000..6d2a4ba538 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor @@ -0,0 +1,54 @@ +@page "/layout" + +
+

Layout Animations (FLIP)

+

+ Add Layout="true" to any Motion element. Whenever the component + re-renders and changes position or size, a FLIP animation plays automatically. +

+ +
+
+
+ @foreach (var item in _items) + { + + @item.Label + + } +
+
+
+ +
+ + +
+
+ +@code { + private string _justifyContent = "flex-start"; + + private record ColoredItem(string Label, string Color); + + private List _items = [ + new("A", "#6c47ff"), + new("B", "#ff4785"), + new("C", "#00c3ff"), + new("D", "#ffd447"), + new("E", "#44ff90"), + ]; + + void Shuffle() => _items = [.._items.OrderBy(_ => Random.Shared.Next())]; + + void ToggleLayout() => _justifyContent = _justifyContent switch + { + "flex-start" => "center", + "center" => "flex-end", + _ => "flex-start" + }; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor new file mode 100644 index 0000000000..d57927a82a --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor @@ -0,0 +1,64 @@ +@page "/scroll" +@inject ScrollTracker Scroll +@implements IAsyncDisposable + +
+
Window scroll progress
+
+
+
+
+ Y: @((_progressY * 100).ToString("F1"))% | X: @((_progressX * 100).ToString("F1"))% +
+
+ +
+

Scroll Tracking

+

+ Inject ScrollTracker and call ObserveAsync to get live scroll + progress. Use the progress value to drive animations via MotionValue. + The progress bar above stays pinned to the top while you scroll. +

+
+ +
+

whileInView

+

+ Use WhileInView to animate when the element enters the viewport. + Set Once="true" to animate only on first entry. +

+ + @for (int i = 0; i < 16; i++) + { + var color = i % 2 == 0 ? "#6c47ff" : "#ff4785"; + var idx = i; + + Section @(idx + 1) + + } +
+ +@code { + private double _progressX; + private double _progressY; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await Scroll.ObserveAsync(null, info => + { + _progressX = info.ProgressX; + _progressY = info.ProgressY; + InvokeAsync(StateHasChanged); + }); + } + } + + public async ValueTask DisposeAsync() => await Scroll.DisposeAsync(); +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor new file mode 100644 index 0000000000..7502207e34 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor @@ -0,0 +1,105 @@ +@page "/springs" + +
+

Spring Physics

+

+ Set Transition.Type = TransitionType.Spring for physically-based motion. + Tune Stiffness (snap speed) and Damping (oscillation). +

+ +
+ @foreach (var cfg in _springs) + { +
+ @cfg.Label + +
+ } +
+ + +
+ +
+

Bouncy Enter

+

Underdamped springs produce delightful overshoots on mount.

+
+
+ +
+
+ +
+ +
+

Repeating Springs

+

+ Springs now honour Repeat, RepeatType and RepeatDelay. + Mirror ping-pongs back to the start, Loop replays from the origin. +

+ +
+
+ Mirror · infinite + +
+ +
+ Loop · 3× with delay + +
+
+ + +
+ +@code { + private bool _on; + private bool _repeat; + private int _mountKey; + + private record SpringDemo(string Label, TransitionConfig Config); + + private readonly SpringDemo[] _springs = + [ + new("Stiff 400 / Damp 40", TransitionConfig.Spring(400, 40)), + new("Stiff 100 / Damp 10", TransitionConfig.Spring(100, 10)), + new("Stiff 50 / Damp 5", TransitionConfig.Spring(50, 5)), + new("Stiff 300 / Damp 70", TransitionConfig.Spring(300, 70)), + ]; + + void Remount() => _mountKey++; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor new file mode 100644 index 0000000000..1f1d00feb6 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor @@ -0,0 +1,83 @@ +@page "/variants" + +
+

Variants

+

+ Define named states in a MotionVariants dictionary and reference them + by name in Animate, Initial, etc. Children automatically receive the + active variant name and can define their own matching entries. +

+ +
+
+ + @for (int i = 0; i < 5; i++) + { + var idx = i; + + Item @(idx + 1) + + } + +
+
+ + +
+ +
+

Variant Orchestration

+

+ Use StaggerChildren and DelayChildren in the container's + transition to choreograph child animations. +

+
+
+ + @for (int i = 0; i < 4; i++) + { + + } + +
+
+ +
+ +@code { + private bool _visible = true; + private bool _visible2 = true; + + // Typed AnimationTarget fields so implicit conversions work cleanly in Razor attributes + private readonly AnimationTarget _hidden = "hidden"; + private readonly AnimationTarget _visible_ = "visible"; + + private readonly MotionVariants _containerVariants = MotionVariants.Create( + ("hidden", new AnimationProps { Opacity = 0 }), + ("visible", new AnimationProps { Opacity = 1 }) + ); + + private readonly MotionVariants _itemVariants = MotionVariants.Create( + ("hidden", new AnimationProps { Opacity = 0, X = -30 }), + ("visible", new AnimationProps { Opacity = 1, X = 0 }) + ); +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Program.cs b/src/Bmotion/Bit.Bmotion.Demos/Program.cs new file mode 100644 index 0000000000..b888075a0e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Program.cs @@ -0,0 +1,15 @@ +using Bit.Bmotion; +using Bit.Bmotion.Demos; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +// Register Bit.Bmotion services +builder.Services.AddBitBmotionServices(); + +await builder.Build().RunAsync(); diff --git a/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json b/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json new file mode 100644 index 0000000000..a5be5829cc --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7106;http://localhost:5235", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor new file mode 100644 index 0000000000..8a7ec83360 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Bit.Bmotion.Demos +@using Bit.Bmotion.Demos.Layout +@using Bit.Bmotion.Components +@using Bit.Bmotion.Models +@using Bit.Bmotion.Services diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css new file mode 100644 index 0000000000..72a74c9b54 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css @@ -0,0 +1,77 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css new file mode 100644 index 0000000000..f2e88139da --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css @@ -0,0 +1,171 @@ +/* Bit.Bmotion Demos — demo styles */ +:root { + --bm-primary: #6c47ff; + --bm-secondary: #ff4785; + --bm-dark: #0a0a0f; + --bm-surface: #141420; + --bm-card: #1e1e2e; + --bm-text: #e8e8f0; + --bm-muted: #888; + --bm-radius: 12px; +} + +body { + background: var(--bm-dark); + color: var(--bm-text); + font-family: 'Segoe UI', system-ui, sans-serif; +} + +/* ── Layout ────────────────────────────────────────────────────────────────── */ + +.bm-nav { + background: var(--bm-surface); + border-bottom: 1px solid rgba(255,255,255,.08); + padding: .75rem 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 100; +} +.bm-nav .logo { + font-weight: 800; + font-size: 1.2rem; + background: linear-gradient(135deg, var(--bm-primary), var(--bm-secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.bm-nav a { + color: var(--bm-muted); + text-decoration: none; + font-size: .9rem; + transition: color .2s; +} +.bm-nav a:hover, .bm-nav a.active { color: var(--bm-text); } + +.bm-page { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +/* ── Demo cards ────────────────────────────────────────────────────────────── */ + +.demo-section { + margin-bottom: 3rem; +} +.demo-section h2 { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: .5rem; +} +.demo-section p { + color: var(--bm-muted); + margin-bottom: 1.5rem; + font-size: .95rem; + line-height: 1.6; +} +.demo-row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: center; +} +.demo-card { + background: var(--bm-card); + border: 1px solid rgba(255,255,255,.07); + border-radius: var(--bm-radius); + padding: 2rem; + min-height: 160px; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 220px; +} + +/* ── Motion boxes ──────────────────────────────────────────────────────────── */ + +.box { + width: 80px; + height: 80px; + border-radius: 10px; + background: linear-gradient(135deg, var(--bm-primary), var(--bm-secondary)); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + user-select: none; +} +.box-sm { width: 50px; height: 50px; border-radius: 8px; } + +/* ── Buttons ───────────────────────────────────────────────────────────────── */ + +.btn-bm { + background: var(--bm-primary); + color: #fff; + border: none; + border-radius: 8px; + padding: .6rem 1.4rem; + font-size: .9rem; + font-weight: 600; + cursor: pointer; + transition: opacity .2s; +} +.btn-bm:hover { opacity: .85; } + +/* ── Code snippets ─────────────────────────────────────────────────────────── */ + +.code-block { + background: #0d0d1a; + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; + padding: 1rem 1.25rem; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: .82rem; + color: #c8c8e0; + overflow-x: auto; + margin-top: 1rem; + white-space: pre; +} + +/* ── Page hero ─────────────────────────────────────────────────────────────── */ + +.page-hero { + text-align: center; + padding: 4rem 0 3rem; +} +.page-hero h1 { + font-size: clamp(2rem, 6vw, 3.5rem); + font-weight: 900; + line-height: 1.1; + margin-bottom: 1rem; + background: linear-gradient(135deg, #fff 30%, var(--bm-primary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.page-hero p { + color: var(--bm-muted); + font-size: 1.15rem; + max-width: 600px; + margin: 0 auto 2rem; + line-height: 1.6; +} +.badge-list { + display: flex; + flex-wrap: wrap; + gap: .5rem; + justify-content: center; +} +.badge { + background: rgba(108,71,255,.2); + border: 1px solid rgba(108,71,255,.4); + color: #a88cff; + padding: .3rem .8rem; + border-radius: 999px; + font-size: .8rem; + font-weight: 600; +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html new file mode 100644 index 0000000000..d7e616bf0f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html @@ -0,0 +1,31 @@ + + + + + + + Bit.Bmotion.Demos + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/src/Bmotion/Bit.Bmotion.slnx b/src/Bmotion/Bit.Bmotion.slnx new file mode 100644 index 0000000000..1e17825ab5 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj new file mode 100644 index 0000000000..534e053f64 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj @@ -0,0 +1,34 @@ + + + + + + net10.0;net9.0;net8.0 + Bit.Bmotion + enable + true + + $(NoWarn);IL2091 + + + + + + + + + + + + + + + + + + diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs new file mode 100644 index 0000000000..cf93e487a4 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs @@ -0,0 +1,40 @@ +using Bit.Bmotion.Engine; +using Bit.Bmotion.Interop; +using Bit.Bmotion.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Bmotion; + +/// +/// Extension methods to register Bit.Bmotion services in the DI container. +/// +public static class BitBmotion +{ + /// + /// Registers all Bit.Bmotion services. + /// Call this in Program.cs before builder.Build(): + /// builder.Services.AddBitBmotionServices(); + /// + public static IServiceCollection AddBitBmotionServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Slim browser-API interop bridge — one instance per DI scope + services.AddScoped(); + + // C# animation engine — drives all animation math in WebAssembly + services.AddScoped(); + + // Higher-level services + // ScrollTracker is owned and disposed by the consuming component (like + // Framer Motion's per-component useScroll), so it must be transient. + // A scoped (app-lifetime in WASM) instance would be disposed by the first + // component to unmount, leaving its DotNetObjectReference disposed and + // causing ObjectDisposedException when another component re-observes. + services.AddTransient(); + services.AddTransient(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor b/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor new file mode 100644 index 0000000000..d8aa9b8776 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor @@ -0,0 +1,11 @@ +@using Bit.Bmotion.Context + +@* AnimatePresence keeps its children alive while they play exit animations, + then stops rendering them once every child reports completion. *@ + +@if (_shouldRender) +{ + + @ChildContent + +} diff --git a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs b/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs new file mode 100644 index 0000000000..be70591295 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs @@ -0,0 +1,77 @@ +using Bit.Bmotion.Context; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bmotion.Components; + +/// +/// Wraps content that should animate in and out. +/// When switches from true to false, children +/// are kept in the DOM until their Exit animations finish. +/// +/// +/// +/// <AnimatePresence IsPresent="@_visible"> +/// <Motion Tag="div" Animate="..." Exit="..." /> +/// </AnimatePresence> +/// +/// +/// +public partial class AnimatePresence : ComponentBase +{ + // ── Parameters ──────────────────────────────────────────────────────────── + + /// + /// Controls whether children are present. Setting to false triggers exit + /// animations before removing children from the DOM. + /// + [Parameter] public bool IsPresent { get; set; } = true; + + /// + /// When true, a new set of children waits for the exiting children to finish + /// before entering. Mirrors Framer Motion's exitBeforeEnter. + /// + [Parameter] public bool ExitBeforeEnter { get; set; } + + [Parameter] public RenderFragment? ChildContent { get; set; } + + // ── Internal state ──────────────────────────────────────────────────────── + + private readonly PresenceContext _presenceCtx = new(); + private bool _shouldRender = true; + private bool _prevIsPresent = true; + + // ═══════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + protected override void OnInitialized() + { + _presenceCtx.AllExitsComplete += OnAllExitsComplete; + } + + protected override void OnParametersSet() + { + if (_prevIsPresent && !IsPresent) + { + // Children are leaving — signal exiting state so Motion components play Exit + _presenceCtx.IsExiting = true; + _shouldRender = true; // keep rendering until exit completes + } + else if (!_prevIsPresent && IsPresent) + { + // Children are re-entering + _presenceCtx.IsExiting = false; + _presenceCtx.Reset(); + _shouldRender = true; + } + + _prevIsPresent = IsPresent; + } + + private void OnAllExitsComplete() + { + _shouldRender = false; + _presenceCtx.IsExiting = false; + InvokeAsync(StateHasChanged); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Components/Motion.cs b/src/Bmotion/Bit.Bmotion/Components/Motion.cs new file mode 100644 index 0000000000..a8cae38d1d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/Motion.cs @@ -0,0 +1,618 @@ +using Bit.Bmotion.Context; +using Bit.Bmotion.Engine; +using Bit.Bmotion.Interop; +using Bit.Bmotion.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.JSInterop; + +namespace Bit.Bmotion.Components; + +/// +/// The primary animation component — a drop-in replacement for any HTML element. +/// Animation math runs in the C# ; JS is used only +/// for DOM style mutation, pointer/focus events, viewport observation and FLIP. +/// +public class Motion : ComponentBase, IAsyncDisposable +{ + // ── Injected services ────────────────────────────────────────────────────── + [Inject] private AnimationEngine Engine { get; set; } = null!; + [Inject] private MotionInterop Interop { get; set; } = null!; + + // ── Cascaded contexts ────────────────────────────────────────────────────── + [CascadingParameter] private PresenceContext? PresenceCtx { get; set; } + [CascadingParameter] private VariantContext? VariantCtx { get; set; } + [CascadingParameter] private MotionConfigContext? ConfigCtx { get; set; } + + // ── Core rendering parameters ──────────────────────────────────────────── + [Parameter] public string Tag { get; set; } = "div"; + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + // ── Animation targets ────────────────────────────────────────────────────── + [Parameter] public AnimationTarget? Initial { get; set; } + [Parameter] public AnimationTarget? Animate { get; set; } + [Parameter] public AnimationTarget? Exit { get; set; } + + // ── Gesture states ───────────────────────────────────────────────────────── + [Parameter] public AnimationTarget? WhileHover { get; set; } + [Parameter] public AnimationTarget? WhileTap { get; set; } + [Parameter] public AnimationTarget? WhileFocus { get; set; } + [Parameter] public AnimationTarget? WhileDrag { get; set; } + [Parameter] public AnimationTarget? WhileInView { get; set; } + + /// + /// If true, fires only once and never deactivates. + /// Shorthand for Viewport = new ViewportOptions { Once = true }. + /// + [Parameter] public bool Once { get; set; } + + /// + /// Advanced viewport options for (margin, amount, once). + /// When set, is ignored in favour of Viewport.Once. + /// + [Parameter] public ViewportOptions? Viewport { get; set; } + + // ── Transition ───────────────────────────────────────────────────────────── + [Parameter] public TransitionConfig? Transition { get; set; } + + // ── Variants ───────────────────────────────────────────────────────────── + [Parameter] public MotionVariants? Variants { get; set; } + + // ── Drag ───────────────────────────────────────────────────────────────── + [Parameter] public bool Drag { get; set; } + [Parameter] public DragOptions? DragOptions { get; set; } + + // ── Layout ───────────────────────────────────────────────────────────────── + [Parameter] public bool Layout { get; set; } + [Parameter] public string? LayoutId { get; set; } + + // ── Events ───────────────────────────────────────────────────────────────── + [Parameter] public EventCallback OnHoverStart { get; set; } + [Parameter] public EventCallback OnHoverEnd { get; set; } + [Parameter] public EventCallback OnTapStart { get; set; } + [Parameter] public EventCallback OnTap { get; set; } + [Parameter] public EventCallback OnTapCancel { get; set; } + [Parameter] public EventCallback OnFocusStart { get; set; } + [Parameter] public EventCallback OnFocusEnd { get; set; } + [Parameter] public EventCallback OnPanStart { get; set; } + [Parameter] public EventCallback OnPan { get; set; } + [Parameter] public EventCallback OnPanEnd { get; set; } + [Parameter] public EventCallback OnDragStart { get; set; } + [Parameter] public EventCallback OnDrag { get; set; } + [Parameter] public EventCallback OnDragEnd { get; set; } + [Parameter] public EventCallback OnAnimationStart { get; set; } + [Parameter] public EventCallback OnAnimationComplete { get; set; } + [Parameter] public EventCallback OnViewportEnter { get; set; } + [Parameter] public EventCallback OnViewportLeave { get; set; } + + // ── Internal state ───────────────────────────────────────────────────────── + private readonly string _id = $"bm-{Guid.NewGuid():N}"; + private ElementReference _ref; + private DotNetObjectReference? _dotnet; + private bool _initialized; + private bool _isExiting; + private AnimationTarget? _prevAnimate; + private VariantContext? _ownVariantCtx; + private string? _prevInheritedVariant; + private int _variantChildIndex = -1; + private BoundingRect? _layoutSnapshot; + + // ════════════════════════════════════════════════════════════════════════════ + // Rendering + // ════════════════════════════════════════════════════════════════════════════ + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + int seq = 0; + builder.OpenElement(seq++, Tag); + builder.AddAttribute(seq++, "id", _id); + + if (AdditionalAttributes != null) + builder.AddMultipleAttributes(seq++, AdditionalAttributes); + + // Auto-inject pathLength="1" so normalized [0,1] dasharray coordinates work correctly + if (Tag == "path" && NeedsPathLengthAttr()) + builder.AddAttribute(seq++, "pathLength", "1"); + + if (!string.IsNullOrEmpty(Class)) + builder.AddAttribute(seq++, "class", Class); + + var motionStyle = BuildInitialStyle(); + var combinedStyle = string.IsNullOrEmpty(Style) ? motionStyle : motionStyle + Style; + if (!string.IsNullOrEmpty(combinedStyle)) + builder.AddAttribute(seq++, "style", combinedStyle); + + builder.AddElementReferenceCapture(seq++, r => _ref = r); + + if (Variants != null) + { + _ownVariantCtx ??= new VariantContext(); + _ownVariantCtx.ActiveVariant = Animate?.IsVariant == true ? Animate.Variant : null; + _ownVariantCtx.InitialVariant = Initial?.IsVariant == true ? Initial.Variant : null; + _ownVariantCtx.Variants = Variants; + _ownVariantCtx.StaggerChildren = Transition?.StaggerChildren ?? 0; + _ownVariantCtx.DelayChildren = Transition?.DelayChildren ?? 0; + + builder.OpenComponent>(seq++); + builder.AddComponentParameter(seq++, "Value", _ownVariantCtx); + builder.AddComponentParameter(seq++, "ChildContent", ChildContent); + builder.CloseComponent(); + } + else + { + builder.AddContent(seq++, ChildContent); + } + builder.CloseElement(); + } + + private string BuildInitialStyle() + { + var props = ResolveProps(Initial); + if (props == null && Animate == null && VariantCtx?.InitialVariant is string initVariant) + props = Variants?.Get(initVariant) ?? VariantCtx.Variants?.Get(initVariant); + return props?.ToCssStyleString() ?? string.Empty; + } + + // ════════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ════════════════════════════════════════════════════════════════════════════ + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotnet = DotNetObjectReference.Create(this); + await InitialiseAsync(); + _initialized = true; + } + else if (_initialized) + { + await HandleParameterUpdateAsync(); + + // FLIP: play layout animation after DOM settles + if (_layoutSnapshot != null) + { + var snap = _layoutSnapshot; + _layoutSnapshot = null; + await PlayFlipAsync(snap); + } + } + } + + protected override async Task OnParametersSetAsync() + { + if (PresenceCtx is { IsExiting: true } && !_isExiting) + { + _isExiting = true; + if (_initialized) await PlayExitAsync(); + } + + // FLIP: snapshot BEFORE re-render + if (_initialized && Layout && !_isExiting) + _layoutSnapshot = await Interop.GetBoundingRectAsync(_id); + } + + private async Task InitialiseAsync() + { + // Reduced-motion is opt-in: only probe the OS preference when this element is + // inside a . Elements without a config always animate normally. + if (ConfigCtx is not null) + await Engine.EnsureReducedMotionDetectedAsync(); + + // Register with C# engine (applies initial values synchronously) + var initProps = ResolveProps(Initial); + Engine.RegisterElement(_id, initProps?.ToJsDictionary()); + + // Mark element in the DOM for JS bridge + await Interop.RegisterElementAsync(_id); + + PresenceCtx?.Register(this); + + // Attach events the JS bridge needs to listen to + var events = BuildEventFlags(); + if (events.Count > 0) + await Interop.AttachEventListenersAsync(_id, events, _dotnet!); + + // Viewport observation — JS IntersectionObserver callbacks C# + if (WhileInView != null || OnViewportEnter.HasDelegate || OnViewportLeave.HasDelegate) + { + if (Viewport != null) + await Interop.ObserveViewportWithOptionsAsync(_id, _dotnet!, Viewport); + else + await Interop.ObserveViewportAsync(_id, _dotnet!, Once); + } + + // Start enter animation + if (Animate != null) + { + var animateProps = ResolveProps(Animate); + if (animateProps != null) + { + await OnAnimationStart.InvokeAsync(); + await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), + () => OnAnimationComplete.InvokeAsync()); + } + } + else if (VariantCtx?.ActiveVariant is string inheritedVariant && Variants != null) + { + _variantChildIndex = VariantCtx.RegisterChild(); + _prevInheritedVariant = inheritedVariant; + var props = Variants.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant); + if (props != null) + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), + BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex))); + } + + _prevAnimate = Animate; + } + + private async Task HandleParameterUpdateAsync() + { + if (_isExiting) return; + + if (!ReferenceEquals(_prevAnimate, Animate)) + { + var animateProps = ResolveProps(Animate); + if (animateProps != null) + { + await OnAnimationStart.InvokeAsync(); + await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), + () => OnAnimationComplete.InvokeAsync()); + } + _prevAnimate = Animate; + } + else if (Animate == null && Variants != null) + { + var newVariant = VariantCtx?.ActiveVariant; + if (newVariant != _prevInheritedVariant) + { + _prevInheritedVariant = newVariant; + if (newVariant != null) + { + var props = Variants.Get(newVariant) ?? VariantCtx?.Variants?.Get(newVariant); + if (props != null) + { + double delay = _variantChildIndex >= 0 ? VariantCtx!.GetChildDelay(_variantChildIndex) : 0; + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), + BuildEffectiveTransitionWithDelay(delay)); + } + } + } + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // Exit & FLIP + // ════════════════════════════════════════════════════════════════════════════ + + internal async Task PlayExitAsync() + { + var exitProps = ResolveProps(Exit); + if (exitProps != null) + await Engine.AnimateToAwaitAsync(_id, exitProps.ToJsDictionary(), BuildEffectiveTransition()); + PresenceCtx?.NotifyExitComplete(this); + } + + private async Task PlayFlipAsync(BoundingRect snap) + { + var cur = await Interop.GetBoundingRectAsync(_id); + if (cur == null) return; + + double dx = snap.Left - cur.Left; + double dy = snap.Top - cur.Top; + double sx = cur.Width > 0 ? snap.Width / cur.Width : 1; + double sy = cur.Height > 0 ? snap.Height / cur.Height : 1; + + if (Math.Abs(dx) < 0.5 && Math.Abs(dy) < 0.5 && Math.Abs(sx - 1) < 0.005 && Math.Abs(sy - 1) < 0.005) + return; + + var t = BuildEffectiveTransition(); + double dur = t?.Type == TransitionType.Spring ? 600 : (t?.Duration ?? 0.5) * 1000; + string easing = t?.Type == TransitionType.Spring + ? "cubic-bezier(0.14,1,0.34,1)" + : EasingFunctions.ToCssString(t); + string? finalT = Engine.GetCurrentTransformString(_id); + + await Interop.PlayWaapiFlipAsync(_id, dx, dy, sx, sy, dur, easing, finalT); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Programmatic API + // ════════════════════════════════════════════════════════════════════════════ + + public async ValueTask AnimateAsync(AnimationProps props, TransitionConfig? transition = null) + { + transition ??= BuildEffectiveTransition(); + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), transition); + } + + public void Set(AnimationProps props) => Engine.SetInstant(_id, props.ToJsDictionary()); + + public async ValueTask SetAsync(AnimationProps props) + { + Engine.SetInstant(_id, props.ToJsDictionary()); + // Flush synchronous style update to DOM + var styles = BuildCssStyleDict(props); + if (styles.Count > 0) + await Interop.ApplyStylesAsync(_id, styles); + } + + public void Stop(params string[] properties) => Engine.Stop(_id, properties.Length > 0 ? properties : null); + + // ════════════════════════════════════════════════════════════════════════════ + // JS → C# callbacks (called from slim JS bridge) + // ════════════════════════════════════════════════════════════════════════════ + + // ── Hover ────────────────────────────────────────────────────────────────── + [JSInvokable] + public async Task OnPointerEnter() + { + var props = ResolveProps(WhileHover); + if (props != null) + await Engine.ActivateGestureLayerAsync(_id, "hover", props.ToJsDictionary(), BuildEffectiveTransition()); + await OnHoverStart.InvokeAsync(); + } + + [JSInvokable] + public async Task OnPointerLeave() + { + if (WhileHover != null) + await Engine.DeactivateGestureLayerAsync(_id, "hover"); + await OnHoverEnd.InvokeAsync(); + } + + // ── Tap ────────────────────────────────────────────────────────────────── + [JSInvokable] + public async Task OnPointerDown() + { + var props = ResolveProps(WhileTap); + if (props != null) + await Engine.ActivateGestureLayerAsync(_id, "tap", props.ToJsDictionary(), BuildEffectiveTransition()); + await OnTapStart.InvokeAsync(); + } + + [JSInvokable] + public async Task OnPointerUp(bool isInsideElement) + { + if (WhileTap != null) + await Engine.DeactivateGestureLayerAsync(_id, "tap"); + if (isInsideElement) await OnTap.InvokeAsync(); + } + + [JSInvokable] + public async Task OnPointerCancel() + { + if (WhileTap != null) + await Engine.DeactivateGestureLayerAsync(_id, "tap"); + await OnTapCancel.InvokeAsync(); + } + + // ── Focus ────────────────────────────────────────────────────────────────── + [JSInvokable] + public async Task OnFocusIn() + { + var props = ResolveProps(WhileFocus); + if (props != null) + await Engine.ActivateGestureLayerAsync(_id, "focus", props.ToJsDictionary(), BuildEffectiveTransition()); + await OnFocusStart.InvokeAsync(); + } + + [JSInvokable] + public async Task OnFocusOut() + { + if (WhileFocus != null) + await Engine.DeactivateGestureLayerAsync(_id, "focus"); + await OnFocusEnd.InvokeAsync(); + } + + // ── Drag ────────────────────────────────────────────────────────────────── + [JSInvokable] + public async Task OnPointerDown_Drag() + { + var props = ResolveProps(WhileDrag); + if (props != null) + await Engine.ActivateGestureLayerAsync(_id, "drag", props.ToJsDictionary(), BuildEffectiveTransition()); + await OnDragStart.InvokeAsync(); + } + + /// Called synchronously from JS for drag position updates (Blazor WASM only). + [JSInvokable] public void SetDragPosition(double x, double y) => Engine.SetDragPosition(_id, x, y); + + /// Called synchronously from JS to get current XY for drag start offset (Blazor WASM only). + [JSInvokable] + public object GetCurrentXY() + { + var (x, y) = Engine.GetCurrentXY(_id); + return new { x, y }; + } + + [JSInvokable] public async Task OnDragMove() => await OnDrag.InvokeAsync(); + + [JSInvokable] + public async Task OnPointerUp_Drag(double velX, double velY) + { + if (WhileDrag != null) + await Engine.DeactivateGestureLayerAsync(_id, "drag"); + + var dragOpt = DragOptions ?? new DragOptions(); + + if (dragOpt.SnapToOrigin) + { + var snapT = dragOpt.SnapTransition ?? new TransitionConfig + { Type = TransitionType.Spring, Stiffness = 400, Damping = 35 }; + await Engine.AnimateToAsync(_id, + new Dictionary { ["x"] = 0.0, ["y"] = 0.0 }, snapT); + } + else + { + await Engine.EndDragAsync( + _id, velX, velY, dragOpt.Momentum, dragOpt.Constraints, + dragOpt.Axis == DragAxis.Both ? null : dragOpt.Axis.ToString().ToLowerInvariant(), + dragOpt.SnapTransition); + } + + await OnDragEnd.InvokeAsync(); + } + + // ── Pan (pointer moves without moving the element) ────────────────────────── + [JSInvokable] + public async Task OnPanStart_() => await OnPanStart.InvokeAsync(); + + [JSInvokable] + public async Task OnPanMove(double pointX, double pointY, + double deltaX, double deltaY, double offsetX, double offsetY, + double velocityX, double velocityY) + { + if (OnPan.HasDelegate) + { + await OnPan.InvokeAsync(new PanInfo + { + Point = new PointInfo { X = pointX, Y = pointY }, + Delta = new PointInfo { X = deltaX, Y = deltaY }, + Offset = new PointInfo { X = offsetX, Y = offsetY }, + Velocity = new PointInfo { X = velocityX, Y = velocityY }, + }); + } + } + + [JSInvokable] + public async Task OnPanEnd_() => await OnPanEnd.InvokeAsync(); + + // ── Viewport ────────────────────────────────────────────────────────────── + [JSInvokable] + public async Task OnIntersect(bool isIntersecting) + { + if (isIntersecting) + { + var props = ResolveProps(WhileInView); + if (props != null) + await Engine.ActivateGestureLayerAsync(_id, "inview", props.ToJsDictionary(), BuildEffectiveTransition()); + await OnViewportEnter.InvokeAsync(); + } + else + { + if (WhileInView != null && !Once) + await Engine.DeactivateGestureLayerAsync(_id, "inview"); + await OnViewportLeave.InvokeAsync(); + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════════════════════════════════ + + private bool NeedsPathLengthAttr() => + (AdditionalAttributes == null || !AdditionalAttributes.ContainsKey("pathLength")) && + (HasPathLength(Initial) || HasPathLength(Animate) || HasPathLength(Exit) || + HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) || + HasPathLength(WhileInView) || HasPathLength(WhileDrag)); + + private static bool HasPathLength(AnimationTarget? t) => + t?.Props?.PathLength != null; + + private AnimationProps? ResolveProps(AnimationTarget? target) + { + if (target == null || target.IsDisabled) return null; + if (target.HasProps) return target.Props; + if (target.IsVariant) + { + var name = target.Variant!; + return Variants?.Get(name) ?? VariantCtx?.Variants?.Get(name); + } + return null; + } + + /// + /// Resolves whether motion should be reduced for this element. + /// + /// Reduced motion is opt-in: an element only reduces motion when it is inside a + /// . Within one, an explicit + /// value (true/false) always wins; when it is + /// null the OS prefers-reduced-motion preference is respected. Elements with no + /// surrounding config always animate, so the OS preference never silently disables animations + /// an app didn't opt into. + /// + /// + private bool ShouldReduceMotion() + { + if (ConfigCtx is null) return false; + return ConfigCtx.ReduceMotion ?? Engine.OsPrefersReducedMotion; + } + + /// An instant (zero-duration) transition used when motion is reduced. + private static TransitionConfig InstantTransition() + => new() { Type = TransitionType.Tween, Duration = 0, Delay = 0 }; + + private TransitionConfig? BuildEffectiveTransition() + { + // Reduced motion: collapse every animation to an instant state change. + if (ShouldReduceMotion()) return InstantTransition(); + + var t = Transition ?? ConfigCtx?.DefaultTransition; + if (t == null) return null; + if (ConfigCtx?.TransitionSpeed is double speed && speed != 1.0) + { + t = t.Clone(); + t.Duration *= speed; + } + return t; + } + + private TransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay) + { + // Reduced motion stays instant — stagger delays are skipped too. + if (ShouldReduceMotion()) return InstantTransition(); + + var t = BuildEffectiveTransition() ?? new TransitionConfig(); + if (extraDelay <= 0) return t; + t = t.Clone(); + t.Delay += extraDelay; + return t; + } + + private Dictionary BuildEventFlags() + { + var d = new Dictionary(); + if (WhileHover != null || OnHoverStart.HasDelegate || OnHoverEnd.HasDelegate) d["hover"] = true; + if (WhileTap != null || OnTapStart.HasDelegate || OnTap.HasDelegate) d["tap"] = true; + if (WhileFocus != null || OnFocusStart.HasDelegate || OnFocusEnd.HasDelegate) d["focus"] = true; + if (OnPanStart.HasDelegate || OnPan.HasDelegate || OnPanEnd.HasDelegate) d["pan"] = true; + if (Drag) + { + d["drag"] = true; + var dragOpt = DragOptions ?? new DragOptions(); + if (dragOpt.Axis != DragAxis.Both) d["dragAxis"] = dragOpt.Axis.ToString().ToLowerInvariant(); + d["dragElastic"] = dragOpt.Elastic; + if (dragOpt.Constraints != null) d["dragConstraints"] = dragOpt.Constraints.ToJsObject(); + if (dragOpt.DirectionLock) d["dragDirectionLock"] = true; + } + return d; + } + + private static Dictionary BuildCssStyleDict(AnimationProps props) + { + var d = new Dictionary(); + // This is only used for instant set() — forward the CSS string parsed from props + var css = props.ToCssStyleString(); + if (!string.IsNullOrEmpty(css)) + d["cssText"] = css; // handled on JS side by parsing cssText + return d; + } + + // ════════════════════════════════════════════════════════════════════════════ + // Dispose + // ════════════════════════════════════════════════════════════════════════════ + + public async ValueTask DisposeAsync() + { + PresenceCtx?.Unregister(this); + Engine.UnregisterElement(_id); + try { await Interop.UnregisterElementAsync(_id); } catch { /* ignore during teardown */ } + try { await Interop.UnobserveViewportAsync(_id); } catch { /* ignore during teardown */ } + _dotnet?.Dispose(); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor new file mode 100644 index 0000000000..a4c208117f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor @@ -0,0 +1,38 @@ +@using Bit.Bmotion.Context +@using Bit.Bmotion.Models + +@* MotionConfig provides global animation defaults to the entire subtree. *@ + + + @ChildContent + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Global default transition applied when no per-component transition is set. + [Parameter] public TransitionConfig? Transition { get; set; } + + /// + /// Global reduce-motion override. + /// null = auto-detect via CSS media query. + /// true = always skip animations. + /// false = always play animations. + /// + [Parameter] public bool? ReduceMotion { get; set; } + + /// + /// Scale factor applied to all durations in this subtree. + /// 0 = instant, 2 = half speed. Default: 1. + /// + [Parameter] public double TransitionSpeed { get; set; } = 1.0; + + private readonly MotionConfigContext _ctx = new(); + + protected override void OnParametersSet() + { + _ctx.DefaultTransition = Transition; + _ctx.ReduceMotion = ReduceMotion; + _ctx.TransitionSpeed = TransitionSpeed; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs new file mode 100644 index 0000000000..1feee4fd1b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs @@ -0,0 +1,24 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Context; + +/// +/// Cascaded by to set library-wide defaults. +/// +public class MotionConfigContext +{ + /// Global default transition applied when no individual transition is set. + public TransitionConfig? DefaultTransition { get; set; } + + /// + /// When true, all animations are skipped (useful for accessibility / reduced-motion). + /// If null the library respects the OS prefers-reduced-motion media query automatically. + /// + public bool? ReduceMotion { get; set; } + + /// + /// Scale factor applied to all animation durations. 0 = instant, 2 = double speed. + /// Default: 1. + /// + public double TransitionSpeed { get; set; } = 1.0; +} diff --git a/src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs b/src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs new file mode 100644 index 0000000000..560a952f09 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs @@ -0,0 +1,33 @@ +using Bit.Bmotion.Components; + +namespace Bit.Bmotion.Context; + +/// +/// Cascaded by to signal exit state to child Motion components. +/// +public class PresenceContext +{ + private readonly List _children = new(); + + /// True while the children are playing their exit animation. + public bool IsExiting { get; internal set; } + + internal void Register(Motion child) => _children.Add(child); + internal void Unregister(Motion child) => _children.Remove(child); + + internal int ChildCount => _children.Count; + + private int _completedExits; + + internal void NotifyExitComplete(Motion child) + { + _completedExits++; + if (_completedExits >= _children.Count) + AllExitsComplete?.Invoke(); + } + + internal void Reset() { _completedExits = 0; _children.Clear(); } + + /// Fired when every registered child has finished its exit animation. + internal event Action? AllExitsComplete; +} diff --git a/src/Bmotion/Bit.Bmotion/Context/VariantContext.cs b/src/Bmotion/Bit.Bmotion/Context/VariantContext.cs new file mode 100644 index 0000000000..94ea54d0fd --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/VariantContext.cs @@ -0,0 +1,36 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Context; + +/// +/// Cascaded by a parent Motion component to propagate the active variant name, +/// shared variants dictionary, and stagger configuration to descendant Motion components. +/// +public class VariantContext +{ + private int _nextChildIndex; + + /// The currently active variant name selected by the nearest ancestor. + public string? ActiveVariant { get; internal set; } + + /// The initial variant name provided by the nearest ancestor. + public string? InitialVariant { get; internal set; } + + /// Shared variants dictionary from the nearest ancestor that defined variants. + public MotionVariants? Variants { get; internal set; } + + /// Seconds to stagger each child's animation start. + public double StaggerChildren { get; internal set; } + + /// Seconds to delay the first child's animation start. + public double DelayChildren { get; internal set; } + + /// + /// Called by a child Motion component once on first render to obtain a stable + /// position in the stagger sequence. Returns the child's index. + /// + internal int RegisterChild() => _nextChildIndex++; + + /// Returns the stagger delay in seconds for a child at the given index. + public double GetChildDelay(int childIndex) => DelayChildren + childIndex * StaggerChildren; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs new file mode 100644 index 0000000000..c3a6c129d8 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs @@ -0,0 +1,331 @@ +using Bit.Bmotion.Interop; +using Bit.Bmotion.Models; +using Microsoft.JSInterop; + +namespace Bit.Bmotion.Engine; + +/// +/// Central C# animation engine — the JS equivalent of the full BitBmotion.js +/// animation loop, now running in Blazor WebAssembly. +/// +/// One instance is shared across the whole component tree (DI scoped). +/// The slim JS bridge calls synchronously each +/// requestAnimationFrame tick and receives back a dictionary of +/// CSS style updates to apply to the DOM. +/// +public sealed class AnimationEngine : IAsyncDisposable +{ + private readonly MotionInterop _interop; + private readonly Dictionary _elements = new(); + private DotNetObjectReference? _dotnet; + private bool _loopRunning; + private bool _reducedMotionDetected; + + public AnimationEngine(MotionInterop interop) => _interop = interop; + + // ═══════════════════════════════════════════════════════════════════════════ + // Reduced-motion (accessibility) + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// The OS-level prefers-reduced-motion preference, detected once via the + /// browser. false until has run. + /// + public bool OsPrefersReducedMotion { get; private set; } + + /// + /// Detects the user's prefers-reduced-motion setting from the browser the + /// first time it is called and caches the result for the lifetime of this engine. + /// + public async ValueTask EnsureReducedMotionDetectedAsync() + { + if (_reducedMotionDetected) return; + _reducedMotionDetected = true; + try + { + OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync(); + } + catch + { + // Detection is best-effort: if the browser probe fails we default to + // animating normally rather than letting it break element initialisation. + OsPrefersReducedMotion = false; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Element lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + /// Register an element and optionally seed its initial CSS state. + public void RegisterElement(string elementId, Dictionary? initialValues = null) + { + if (!_elements.TryGetValue(elementId, out var state)) + { + state = new ElementAnimationState(); + _elements[elementId] = state; + } + if (initialValues != null) + state.SetInstant(initialValues); + } + + /// Remove an element and cancel all its animations. + public void UnregisterElement(string elementId) + { + if (_elements.TryGetValue(elementId, out var state)) + { + state.CancelAll(); + _elements.Remove(elementId); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Animation control + // ═══════════════════════════════════════════════════════════════════════════ + + /// Start animating to the given values. Returns immediately (fire-and-forget). + public async ValueTask AnimateToAsync( + string elementId, + Dictionary values, + TransitionConfig? transition, + Func? onComplete = null) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + state.SetBaseAnimation(values, transition); + if (onComplete != null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.AnimateTo(values, transition, tcs); + await EnsureLoopRunningAsync(); + _ = tcs.Task.ContinueWith(_ => onComplete(), TaskScheduler.Default); + } + else + { + state.AnimateTo(values, transition); + await EnsureLoopRunningAsync(); + } + } + + /// Animate to the given values and await animation completion. + public async ValueTask AnimateToAwaitAsync( + string elementId, + Dictionary values, + TransitionConfig? transition) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.SetBaseAnimation(values, transition); + state.AnimateTo(values, transition, tcs); + await EnsureLoopRunningAsync(); + await tcs.Task; + } + + /// Instantly set values without any animation. + public void SetInstant(string elementId, Dictionary values) + { + if (_elements.TryGetValue(elementId, out var state)) + state.SetInstant(values); + } + + /// Stop animations on specific properties (or all when is null/empty). + public void Stop(string elementId, string[]? properties) + { + if (_elements.TryGetValue(elementId, out var state)) + state.Cancel(properties); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Gesture layer management + // ═══════════════════════════════════════════════════════════════════════════ + + public async ValueTask ActivateGestureLayerAsync( + string elementId, string gesture, + Dictionary values, TransitionConfig? transition) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + state.ActivateGestureLayer(gesture, values, transition); + await EnsureLoopRunningAsync(); + } + + public async ValueTask DeactivateGestureLayerAsync(string elementId, string gesture) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + state.DeactivateGestureLayer(gesture); + await EnsureLoopRunningAsync(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Drag position (called synchronously from JS — Blazor WASM only) + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Updates the drag position in the element's transform state from a + /// synchronous JS pointer-move call. The position will be included in the + /// next output. + /// + public void SetDragPosition(string elementId, double x, double y) + { + if (_elements.TryGetValue(elementId, out var state)) + state.SetDragPosition(x, y); + } + + /// Returns the current transform x/y for an element (used at drag start). + public (double x, double y) GetCurrentXY(string elementId) + { + return _elements.TryGetValue(elementId, out var state) + ? state.GetCurrentXY() + : (0, 0); + } + + /// + /// Completes a drag and optionally starts inertia animations. + /// + public async ValueTask EndDragAsync( + string elementId, + double velX, double velY, + bool momentum, + DragConstraints? constraints, + string? axis, + TransitionConfig? snapTransition) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + + state.EndDrag(); + + var (posX, posY) = state.GetCurrentXY(); + + if (momentum) + { + var snapT = snapTransition ?? new TransitionConfig + { Type = TransitionType.Spring, Stiffness = 400, Damping = 35 }; + + if (axis != "y" && Math.Abs(velX) > 0.5) + { + var inertiaX = new TransitionConfig + { + Type = TransitionType.Inertia, + InertiaVelocity = velX * 50, + InertiaMin = constraints?.Left, + InertiaMax = constraints?.Right, + }; + var valuesX = new Dictionary { ["x"] = posX }; + state.AnimateTo(valuesX, inertiaX); + } + + if (axis != "x" && Math.Abs(velY) > 0.5) + { + var inertiaY = new TransitionConfig + { + Type = TransitionType.Inertia, + InertiaVelocity = velY * 50, + InertiaMin = constraints?.Top, + InertiaMax = constraints?.Bottom, + }; + var valuesY = new Dictionary { ["y"] = posY }; + state.AnimateTo(valuesY, inertiaY); + } + } + else if (constraints != null) + { + // Snap to constraint bounds + double cx = posX, cy = posY; + bool snap = false; + var snapT = snapTransition ?? new TransitionConfig + { Type = TransitionType.Spring, Stiffness = 400, Damping = 35 }; + + if (axis != "y") + { + if (constraints.Left.HasValue && cx < constraints.Left.Value) { cx = constraints.Left.Value; snap = true; } + if (constraints.Right.HasValue && cx > constraints.Right.Value) { cx = constraints.Right.Value; snap = true; } + } + if (axis != "x") + { + if (constraints.Top.HasValue && cy < constraints.Top.Value) { cy = constraints.Top.Value; snap = true; } + if (constraints.Bottom.HasValue && cy > constraints.Bottom.Value) { cy = constraints.Bottom.Value; snap = true; } + } + + if (snap) + { + var snapValues = new Dictionary(); + if (axis != "y") snapValues["x"] = cx; + if (axis != "x") snapValues["y"] = cy; + state.AnimateTo(snapValues, snapT); + } + } + + if (state.HasActiveAnimations) + await EnsureLoopRunningAsync(); + } + + /// Returns the current CSS transform string for the element (used by FLIP). + public string? GetCurrentTransformString(string elementId) + { + if (!_elements.TryGetValue(elementId, out var state)) return null; + return TransformComposer.Build(state.Transforms); + } + + /// Returns the for an element, or null. + internal ElementAnimationState? GetState(string elementId) + => _elements.GetValueOrDefault(elementId); + + // ═══════════════════════════════════════════════════════════════════════════ + // rAF loop — ComputeFrame is called synchronously from JS each tick + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Called synchronously by the JS rAF ticker every ~16 ms (Blazor WASM). + /// Returns a dictionary: elementId → { cssPropertyName → cssValue }. + /// Returns null when there is nothing to animate (JS will stop the loop). + /// + [JSInvokable] + public Dictionary>? ComputeFrame(double timestamp) + { + Dictionary>? result = null; + bool anyActive = false; + + foreach (var (id, state) in _elements) + { + var updates = state.Tick(timestamp); + if (updates is { Count: > 0 }) + { + result ??= new Dictionary>(); + result[id] = updates; + } + if (state.HasActiveAnimations) anyActive = true; + } + + if (!anyActive) + StopLoopInternal(); + + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Loop lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + public async ValueTask EnsureLoopRunningAsync() + { + if (_loopRunning) return; + _loopRunning = true; + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.StartRafLoopAsync(_dotnet); + } + + private void StopLoopInternal() + { + if (!_loopRunning) return; + _loopRunning = false; + _ = _interop.StopRafLoopAsync(); + } + + public async ValueTask DisposeAsync() + { + foreach (var (_, state) in _elements) + state.CancelAll(); + _elements.Clear(); + StopLoopInternal(); + _dotnet?.Dispose(); + await _interop.DisposeAsync(); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs new file mode 100644 index 0000000000..2b8a11cf5b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs @@ -0,0 +1,97 @@ +namespace Bit.Bmotion.Engine; + +/// +/// Pure-C# RGBA color parsing and linear interpolation. +/// Handles #hex, rgb(), rgba(), hsl(), and hsla() formats. +/// +internal static class ColorInterpolator +{ + /// Linearly interpolates between two CSS color strings at progress (0–1). + public static string Lerp(string from, string to, double t) + { + var f = Parse(from); + var tt = Parse(to); + if (f == null || tt == null) return to; + + int r = (int)Math.Round(f[0] + (tt[0] - f[0]) * t); + int g = (int)Math.Round(f[1] + (tt[1] - f[1]) * t); + int b = (int)Math.Round(f[2] + (tt[2] - f[2]) * t); + double a = f[3] + (tt[3] - f[3]) * t; + return $"rgba({r},{g},{b},{a:G4})"; + } + + /// Returns true if the CSS string looks like a color value. + public static bool LooksLikeColor(string? value) + => value != null && + (value.StartsWith('#') || + value.StartsWith("rgb", StringComparison.OrdinalIgnoreCase) || + value.StartsWith("hsl", StringComparison.OrdinalIgnoreCase)); + + // ── Internal ────────────────────────────────────────────────────────────── + + private static double[]? Parse(string c) + { + if (string.IsNullOrEmpty(c)) return null; + + if (c.StartsWith('#')) + { + var h = c[1..]; + // Expand shorthand #rgb → #rrggbb, #rgba → #rrggbbaa + if (h.Length == 3 || h.Length == 4) + h = string.Concat(h.Select(ch => $"{ch}{ch}")); + if (h.Length < 6) return null; + return + [ + Convert.ToInt32(h[..2], 16), + Convert.ToInt32(h[2..4], 16), + Convert.ToInt32(h[4..6], 16), + h.Length >= 8 ? Convert.ToInt32(h[6..8], 16) / 255.0 : 1.0, + ]; + } + + // rgb() / rgba() + var m = System.Text.RegularExpressions.Regex.Match( + c, @"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)"); + if (m.Success) + { + return + [ + double.Parse(m.Groups[1].Value), + double.Parse(m.Groups[2].Value), + double.Parse(m.Groups[3].Value), + m.Groups[4].Success ? double.Parse(m.Groups[4].Value) : 1.0, + ]; + } + + // hsl() / hsla() + var mh = System.Text.RegularExpressions.Regex.Match( + c, @"hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)"); + if (mh.Success) + { + double h2 = double.Parse(mh.Groups[1].Value); + double s2 = double.Parse(mh.Groups[2].Value) / 100.0; + double l2 = double.Parse(mh.Groups[3].Value) / 100.0; + double a2 = mh.Groups[4].Success ? double.Parse(mh.Groups[4].Value) : 1.0; + var rgb2 = HslToRgb(h2, s2, l2); + return [rgb2[0], rgb2[1], rgb2[2], a2]; + } + + return null; + } + + private static double[] HslToRgb(double h, double s, double l) + { + h = ((h % 360) + 360) % 360; // normalise to 0-360 + double c = (1 - Math.Abs(2 * l - 1)) * s; + double x = c * (1 - Math.Abs((h / 60) % 2 - 1)); + double m = l - c / 2; + double r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return [(r + m) * 255, (g + m) * 255, (b + m) * 255]; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs new file mode 100644 index 0000000000..5e08ad17ef --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs @@ -0,0 +1,66 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// Tween animation driver for CSS color string properties. +internal sealed class ColorTweenDriver : IAnimationDriver +{ + private readonly string _to; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly Func _easeFn; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly RepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private string _curFrom; + private string _curTo; + + public ColorTweenDriver(string from, string to, TransitionConfig config, Action apply) + { + _curFrom = from; + _curTo = _to = to; + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _easeFn = EasingFunctions.Get(config); + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_to); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrom); return false; } + + double elapsed = timestamp - _startTime; + double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0; + double p = _easeFn(t); + _apply(ColorInterpolator.Lerp(_curFrom, _curTo, p)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + (_curFrom, _curTo) = (_curTo, _curFrom); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs b/src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs new file mode 100644 index 0000000000..1467833852 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs @@ -0,0 +1,81 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// +/// Pure-C# easing functions. Ported from the original JS implementation. +/// Cached delegates avoid re-allocation for common easing types. +/// +internal static class EasingFunctions +{ + // ── Pre-built delegates for common easings ──────────────────────────────── + private static readonly Func _easeIn = CubicBezier(0.42, 0, 1, 1); + private static readonly Func _easeOut = CubicBezier(0, 0, 0.58, 1); + private static readonly Func _easeInOut = CubicBezier(0.42, 0, 0.58, 1); + private static readonly Func _backIn = CubicBezier(0.31455, -0.37755, 0.69245, 1.37755); + private static readonly Func _backOut = CubicBezier(0.33915, 0, 0.68085, 1.4); + private static readonly Func _backInOut = CubicBezier(0.68987, -0.45, 0.32, 1.45); + + /// Returns an easing function for the given transition config. + public static Func Get(TransitionConfig config) + { + if (config.EaseCubicBezier is { Length: 4 } cb) + return CubicBezier(cb[0], cb[1], cb[2], cb[3]); + + return config.Ease switch + { + Easing.Linear => t => t, + Easing.EaseIn => _easeIn, + Easing.EaseOut => _easeOut, + Easing.EaseInOut => _easeInOut, + Easing.CircIn => t => 1 - Math.Sqrt(1 - t * t), + Easing.CircOut => t => Math.Sqrt(1 - (t - 1) * (t - 1)), + Easing.CircInOut => t => t < 0.5 + ? (1 - Math.Sqrt(1 - 4 * t * t)) / 2 + : (Math.Sqrt(1 - Math.Pow(2 * t - 2, 2)) + 1) / 2, + Easing.BackIn => _backIn, + Easing.BackOut => _backOut, + Easing.BackInOut => _backInOut, + Easing.Anticipate => t => t < 0.5 + ? _backIn(t * 2) / 2 + : _easeOut(t * 2 - 1) / 2 + 0.5, + _ => _easeOut, + }; + } + + /// Returns a CSS easing string for use with the Web Animations API (FLIP). + public static string ToCssString(TransitionConfig? config) + { + if (config == null) return "ease"; + if (config.EaseCubicBezier is { Length: 4 } cb) + return $"cubic-bezier({cb[0]},{cb[1]},{cb[2]},{cb[3]})"; + return config.Ease switch + { + Easing.Linear => "linear", + Easing.EaseIn => "ease-in", + Easing.EaseOut => "ease-out", + Easing.EaseInOut => "ease-in-out", + _ => "ease", + }; + } + + /// Constructs a cubic-bezier easing function via Newton-Raphson iteration. + public static Func CubicBezier(double x1, double y1, double x2, double y2) + { + return t => + { + if (t <= 0) return 0; + if (t >= 1) return 1; + double u = t; + for (int i = 0; i < 10; i++) + { + double bx = 3 * u * (1 - u) * (1 - u) * x1 + 3 * u * u * (1 - u) * x2 + u * u * u - t; + double dbx = 3 * (1 - u) * (1 - u) * x1 + 6 * u * (1 - u) * x2 - 6 * u * (1 - u) * x1 + 3 * u * u; + if (Math.Abs(dbx) < 1e-8) break; + u -= bx / dbx; + u = Math.Max(0, Math.Min(1, u)); + } + return 3 * u * (1 - u) * (1 - u) * y1 + 3 * u * u * (1 - u) * y2 + u * u * u; + }; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs new file mode 100644 index 0000000000..286c8402f2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs @@ -0,0 +1,393 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// +/// Per-element animation state — the C# equivalent of the JS ElementState class. +/// Holds current transform / numeric / color values, active animation drivers, +/// and gesture-layer bookkeeping. Called by +/// every rAF tick. +/// +internal sealed class ElementAnimationState +{ + // ── Live CSS values ─────────────────────────────────────────────────────── + + /// Current values of transform components (x, y, scale, rotate, …). + internal readonly Dictionary Transforms = new(); + + /// Current values of numeric non-transform properties (opacity, pathLength, …). + internal readonly Dictionary NumericValues = new(); + + /// Current values of color / string properties (backgroundColor, color, …). + internal readonly Dictionary StringValues = new(); + + // ── Active animations ───────────────────────────────────────────────────── + private readonly Dictionary _activeAnims = new(); + + // ── Gesture layer stack ──────────────────────────────────────────────────── + private static readonly string[] GesturePriority = ["drag", "focus", "tap", "hover", "inview"]; + private readonly Dictionary _gestureLayers = new(); + private Dictionary? _baseValues; + private TransitionConfig? _baseTransition; + + // ── Animation completion tracking ───────────────────────────────────────── + private TaskCompletionSource? _completionSource; + + // ── Drag state ──────────────────────────────────────────────────────────── + private bool _isDragging; + + // ── Dirty flags for CSS build ───────────────────────────────────────────── + private bool _transformDirty; + private readonly HashSet _dirtyProps = new(); + + public bool HasActiveAnimations => _activeAnims.Count > 0 || _isDragging; + + // ═══════════════════════════════════════════════════════════════════════════ + // Tick — called every rAF frame + // ═══════════════════════════════════════════════════════════════════════════ + + public Dictionary? Tick(double timestamp) + { + if (_activeAnims.Count == 0 && !_isDragging) return null; + + _transformDirty = _isDragging; // drag always refreshes transform + _dirtyProps.Clear(); + + // Advance all drivers + var completed = new List(_activeAnims.Count); + foreach (var (key, driver) in _activeAnims) + { + if (driver.Tick(timestamp)) + completed.Add(key); + } + + foreach (var key in completed) + _activeAnims.Remove(key); + + // Signal awaiter if all finished + if (_completionSource != null && _activeAnims.Count == 0) + { + _completionSource.TrySetResult(); + _completionSource = null; + } + + if (!_transformDirty && _dirtyProps.Count == 0) return null; + + // ── Build CSS style update dict ──────────────────────────────────────── + var updates = new Dictionary(_dirtyProps.Count + 1); + + if (_transformDirty) + updates["transform"] = TransformComposer.Build(Transforms); + + foreach (var prop in _dirtyProps) + { + if (prop == "pathLength") + { + double v = NumericValues.GetValueOrDefault("pathLength", 1.0); + double clamped = Math.Max(0, Math.Min(1, v)); + updates["strokeDasharray"] = "1 1"; + updates["strokeDashoffset"] = (1 - clamped).ToString("G6"); + } + else if (prop == "pathOffset") + { + updates["strokeDashoffset"] = (-NumericValues.GetValueOrDefault("pathOffset", 0)).ToString("G6"); + } + else if (prop.StartsWith("--")) + { + if (NumericValues.TryGetValue(prop, out double nv)) + updates[prop] = nv.ToString("G6"); + else if (StringValues.TryGetValue(prop, out string? sv)) + updates[prop] = sv; + } + else if (NumericValues.TryGetValue(prop, out double numVal)) + { + updates[prop] = numVal.ToString("G6"); + } + else if (StringValues.TryGetValue(prop, out string? strVal)) + { + updates[prop] = strVal; + } + } + + return updates.Count > 0 ? updates : null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Animation control + // ═══════════════════════════════════════════════════════════════════════════ + + public void AnimateTo( + Dictionary values, + TransitionConfig? transition, + TaskCompletionSource? completionSource = null) + { + var entries = values.Where(kv => kv.Value != null).ToList(); + if (entries.Count == 0) { completionSource?.TrySetResult(); return; } + + _completionSource = completionSource; + + foreach (var (key, value) in entries) + { + var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new TransitionConfig(); + CancelProp(key); + + if (TryGetDoubleArray(value, out double[]? doubleFrames)) + CreateNumericKeyframesDriver(key, doubleFrames!, perKey); + else if (TryGetStringArray(value, out string[]? strFrames)) + CreateColorKeyframesDriver(key, strFrames!, perKey); + else if (IsColorProp(key) && value is string colorStr) + CreateColorDriver(key, colorStr, perKey); + else if (value is string dimStr) + CreateCssDimensionDriver(key, dimStr, perKey); + else + CreateNumericDriver(key, Convert.ToDouble(value), perKey); + } + } + + public void SetInstant(Dictionary values) + { + foreach (var (key, value) in values) + { + if (value == null) continue; + if (TransformComposer.IsTransformProp(key)) + { + Transforms[key] = Convert.ToDouble(value); + _transformDirty = true; + } + else if (IsColorProp(key) && value is string colorStr) + { + StringValues[key] = colorStr; + _dirtyProps.Add(key); + } + else if (value is string dimStr) + { + StringValues[key] = dimStr; + _dirtyProps.Add(key); + } + else + { + NumericValues[key] = Convert.ToDouble(value); + _dirtyProps.Add(key); + } + } + } + + public void Cancel(string[]? properties) + { + if (properties == null || properties.Length == 0) + CancelAll(); + else + foreach (var p in properties) + CancelProp(p); + } + + internal void CancelAll() + { + foreach (var driver in _activeAnims.Values) + driver.Cancel(); + _activeAnims.Clear(); + _completionSource?.TrySetResult(); + _completionSource = null; + } + + internal void CancelProp(string key) + { + if (_activeAnims.TryGetValue(key, out var driver)) + { + driver.Cancel(); + _activeAnims.Remove(key); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Gesture layer management + // ═══════════════════════════════════════════════════════════════════════════ + + public void SetBaseAnimation(Dictionary values, TransitionConfig? transition) + { + _baseValues = values; + _baseTransition = transition; + } + + public void ActivateGestureLayer(string gesture, Dictionary values, TransitionConfig? transition) + { + _gestureLayers[gesture] = new GestureLayer(values, transition); + AnimateTo(values, transition); + } + + public void DeactivateGestureLayer(string gesture) + { + _gestureLayers.Remove(gesture); + // Revert to the highest-priority remaining gesture or base + foreach (var priority in GesturePriority) + { + if (_gestureLayers.TryGetValue(priority, out var remaining)) + { + AnimateTo(remaining.Values, remaining.Transition); + return; + } + } + if (_baseValues != null) + AnimateTo(_baseValues, _baseTransition); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Drag position (updated synchronously from JS pointer events) + // ═══════════════════════════════════════════════════════════════════════════ + + public void SetDragPosition(double x, double y) + { + Transforms["x"] = x; + Transforms["y"] = y; + _isDragging = true; + _transformDirty = true; + } + + public void EndDrag() => _isDragging = false; + + public (double x, double y) GetCurrentXY() + => (Transforms.GetValueOrDefault("x"), Transforms.GetValueOrDefault("y")); + + // ═══════════════════════════════════════════════════════════════════════════ + // Driver factory helpers + // ═══════════════════════════════════════════════════════════════════════════ + + private void CreateNumericDriver(string key, double toValue, TransitionConfig config) + { + bool isTransform = TransformComposer.IsTransformProp(key); + double from = isTransform + ? Transforms.GetValueOrDefault(key, DefaultTransformValue(key)) + : NumericValues.GetValueOrDefault(key, DefaultNumericValue(key)); + + Action apply = isTransform + ? v => ApplyTransform(key, v) + : v => ApplyNumeric(key, v); + + IAnimationDriver driver = config.Type switch + { + TransitionType.Spring => new SpringDriver(from, toValue, config, apply), + TransitionType.Inertia => new InertiaDriver(from, config, apply), + _ => new TweenDriver(from, toValue, config, apply), + }; + + _activeAnims[key] = driver; + } + + private void CreateColorDriver(string key, string toValue, TransitionConfig config) + { + string from = StringValues.GetValueOrDefault(key, "rgba(0,0,0,0)"); + _activeAnims[key] = new ColorTweenDriver(from, toValue, config, v => ApplyString(key, v)); + } + + private void CreateNumericKeyframesDriver(string key, double[] frames, TransitionConfig config) + { + bool isTransform = TransformComposer.IsTransformProp(key); + Action apply = isTransform + ? v => ApplyTransform(key, v) + : v => ApplyNumeric(key, v); + _activeAnims[key] = new NumericKeyframesDriver(frames, config, apply); + } + + private void CreateColorKeyframesDriver(string key, string[] frames, TransitionConfig config) + { + _activeAnims[key] = new ColorKeyframesDriver(frames, config, v => ApplyString(key, v)); + } + + // ── Value apply callbacks (mark dirty) ──────────────────────────────────── + + private void ApplyTransform(string key, double value) + { + Transforms[key] = value; + _transformDirty = true; + } + + private void ApplyNumeric(string key, double value) + { + NumericValues[key] = value; + _dirtyProps.Add(key); + } + + private void ApplyString(string key, string value) + { + StringValues[key] = value; + _dirtyProps.Add(key); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static readonly HashSet _colorProps = new(StringComparer.OrdinalIgnoreCase) + { + "backgroundColor", "color", "borderColor", "outlineColor", "fill", "stroke", + "caretColor", "columnRuleColor", "textDecorationColor", + }; + + private static bool IsColorProp(string key) + => _colorProps.Contains(key) || key.Contains("color", StringComparison.OrdinalIgnoreCase); + + private static double DefaultTransformValue(string key) => + key is "scale" or "scaleX" or "scaleY" ? 1.0 : 0.0; + + private static double DefaultNumericValue(string key) => + key is "opacity" or "pathLength" ? 1.0 : 0.0; + + private static bool TryGetDoubleArray(object? value, out double[]? result) + { + result = null; + if (value is double[] da) { result = da; return true; } + if (value is IEnumerable de) { result = de.ToArray(); return true; } + if (value is object[] oa && oa.Length > 0 && oa[0] is double or float or int or long) + { + result = oa.Select(x => Convert.ToDouble(x)).ToArray(); + return true; + } + return false; + } + + private void CreateCssDimensionDriver(string key, string toValue, TransitionConfig config) + { + // If both from and to are the same unit, interpolate numerically. + // Otherwise just snap to the new value immediately. + string fromRaw = StringValues.GetValueOrDefault(key, ""); + if (TryParseCssDimension(toValue, out double toNum, out string toUnit) && + TryParseCssDimension(fromRaw, out double fromNum, out string fromUnit) && + string.Equals(fromUnit, toUnit, StringComparison.OrdinalIgnoreCase)) + { + _activeAnims[key] = new TweenDriver(fromNum, toNum, config, + v => ApplyString(key, v.ToString("G6") + toUnit)); + } + else + { + // Snap and mark dirty — no interpolation possible across different units. + StringValues[key] = toValue; + _dirtyProps.Add(key); + } + } + + private static bool TryParseCssDimension(string value, out double number, out string unit) + { + if (string.IsNullOrEmpty(value)) { number = 0; unit = ""; return false; } + // Find the split between leading numeric part and trailing unit. + int i = 0; + if (i < value.Length && (value[i] == '-' || value[i] == '+')) i++; + while (i < value.Length && (char.IsDigit(value[i]) || value[i] == '.')) i++; + if (i == 0 || (i == 1 && (value[0] == '-' || value[0] == '+'))) + { number = 0; unit = ""; return false; } + unit = value[i..]; + return double.TryParse(value[..i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out number); + } + + private static bool TryGetStringArray(object? value, out string[]? result) + { + result = null; + if (value is string[] sa) { result = sa; return true; } + if (value is object[] oa && oa.Length > 0 && oa[0] is string) + { + result = oa.Cast().ToArray(); + return true; + } + return false; + } + + private sealed record GestureLayer(Dictionary Values, TransitionConfig? Transition); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs new file mode 100644 index 0000000000..a8997113a1 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs @@ -0,0 +1,20 @@ +namespace Bit.Bmotion.Engine; + +/// +/// Single animation driver interface. +/// Each driver owns the callback that applies the animated value to +/// state dictionaries. +/// Returns true from when the animation is complete. +/// +internal interface IAnimationDriver +{ + /// + /// Advance the animation to (milliseconds, matching performance.now()). + /// Calls the apply-callback with the current value. + /// Returns true when the animation has finished and may be removed. + /// + bool Tick(double timestamp); + + /// Cancel the animation, snapping to its target value. + void Cancel(); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs new file mode 100644 index 0000000000..14eadde075 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs @@ -0,0 +1,67 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// +/// Exponential-decay inertia driver. Decelerates from an initial velocity toward +/// an optional projected target, with optional bounds clamping. +/// +internal sealed class InertiaDriver : IAnimationDriver +{ + private readonly double _start; + private readonly double _projected; + private readonly double _delta; + private readonly double _timeConstantSec; + private readonly double _restDelta; + private readonly double _delayMs; + private readonly Action _apply; + + private double _elapsed; + private double _lastTs = -1; + private double _startTs = -1; + private bool _cancelled; + + public InertiaDriver(double from, TransitionConfig config, Action apply) + { + _start = from; + _timeConstantSec = config.TimeConstant / 1000.0; + _restDelta = config.InertiaRestDelta; + _delayMs = config.Delay * 1000; + _apply = apply; + + double power = config.Power; + double velocity = config.InertiaVelocity; + + double projected = from + power * velocity; + if (config.InertiaMax.HasValue) projected = Math.Min(projected, config.InertiaMax.Value); + if (config.InertiaMin.HasValue) projected = Math.Max(projected, config.InertiaMin.Value); + + _projected = projected; + _delta = projected - from; + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_projected); return true; } + + if (_startTs < 0) _startTs = timestamp; + if (timestamp - _startTs < _delayMs) { _apply(_start); return false; } + + if (_lastTs < 0) _lastTs = timestamp; + + _elapsed += Math.Min((timestamp - _lastTs) / 1000.0, 0.064); + _lastTs = timestamp; + + double pos = _start + _delta * (1 - Math.Exp(-_elapsed / _timeConstantSec)); + _apply(pos); + + if (Math.Abs(_projected - pos) < _restDelta) + { + _apply(_projected); + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs new file mode 100644 index 0000000000..dbde099ca2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs @@ -0,0 +1,158 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// Keyframe animation driver for numeric (double) properties. +internal sealed class NumericKeyframesDriver : IAnimationDriver +{ + private readonly double[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly RepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private double[] _curFrames; + + public NumericKeyframesDriver(double[] frames, TransitionConfig config, Action apply) + { + _frames = frames; + _curFrames = (double[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + + // Per-segment easing: if ease is an array of length n-1, use one per segment; otherwise use same for all + _eases = new Func[n - 1]; + var globalEase = EasingFunctions.Get(config); + for (int i = 0; i < n - 1; i++) + _eases[i] = globalEase; + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_frames[^1]); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; + _apply(Interpolate(_curFrames, _times, _eases, t)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + Array.Reverse(_curFrames); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + private static double Interpolate(double[] frames, double[] times, Func[] eases, double t) + { + int n = frames.Length; + int seg = n - 2; + for (int i = 0; i < n - 1; i++) + { + if (t <= times[i + 1]) { seg = i; break; } + } + double segLen = times[seg + 1] - times[seg]; + double segT = segLen > 0 ? (t - times[seg]) / segLen : 1.0; + double easedT = eases[seg](Math.Min(segT, 1.0)); + return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT; + } +} + +/// Keyframe animation driver for CSS color string properties. +internal sealed class ColorKeyframesDriver : IAnimationDriver +{ + private readonly string[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly RepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private string[] _curFrames; + + public ColorKeyframesDriver(string[] frames, TransitionConfig config, Action apply) + { + _frames = frames; + _curFrames = (string[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + var globalEase = EasingFunctions.Get(config); + _eases = Enumerable.Repeat(globalEase, n - 1).ToArray(); + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_frames[^1]); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; + + int n = _curFrames.Length; + int seg = n - 2; + for (int i = 0; i < n - 1; i++) { if (t <= _times[i + 1]) { seg = i; break; } } + double segLen = _times[seg + 1] - _times[seg]; + double segT = segLen > 0 ? (t - _times[seg]) / segLen : 1.0; + double easedT = _eases[seg](Math.Min(segT, 1.0)); + _apply(ColorInterpolator.Lerp(_curFrames[seg], _curFrames[seg + 1], easedT)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + Array.Reverse(_curFrames); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs new file mode 100644 index 0000000000..64eceb6190 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs @@ -0,0 +1,116 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// +/// Semi-implicit Euler spring physics driver for numeric properties. +/// Automatically subdivides each frame to maintain numerical stability for +/// high-stiffness / high-damping configurations. +/// +internal sealed class SpringDriver : IAnimationDriver +{ + private double _target; + private double _from; + private readonly double _k; // stiffness + private readonly double _d; // damping + private readonly double _m; // mass + private readonly double _initialVel; + private readonly double _restSpeed; + private readonly double _restDelta; + private readonly double _repeatDelayMs; + private readonly double _maxSubDt; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly RepeatType _repeatType; + private readonly Action _apply; + + private double _pos; + private double _vel; + private double _currentDelayMs; + private double _lastTs = -1; + private double _startTs = -1; + private int _iteration; + private bool _cancelled; + + public SpringDriver(double from, double to, TransitionConfig config, Action apply) + { + _pos = _from = from; + _target = to; + + // Resolve stiffness/damping: if Bounce+VisualDuration (or Bounce+Duration) are set, + // derive them from those intuitive parameters (Framer Motion-compatible). + double k = config.Stiffness; + double d = config.Damping; + if (config.Bounce.HasValue) + { + double vd = config.VisualDuration ?? config.Duration; + (k, d) = TransitionConfig.SpringFromBounce(vd, config.Bounce.Value, config.Mass); + } + + _k = k; + _d = d; + _m = config.Mass; + _vel = _initialVel = config.Velocity; + _restSpeed = config.RestSpeed; + _restDelta = config.RestDelta; + _currentDelayMs = config.Delay * 1000; + _repeatDelayMs = config.RepeatDelay * 1000; + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _apply = apply; + + // Compute a maximum sub-step size that keeps semi-implicit Euler stable + _maxSubDt = Math.Max(0.001, Math.Min( + _d > 0 ? 1.8 / _d : 1.0, + _k > 0 ? 0.9 / Math.Sqrt(_k) : 1.0)); + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_target); return true; } + + if (_startTs < 0) _startTs = timestamp; + if (timestamp - _startTs < _currentDelayMs) { _apply(_pos); return false; } + + if (_lastTs < 0) _lastTs = timestamp; + + double dt = Math.Min((timestamp - _lastTs) / 1000.0, 0.064); + _lastTs = timestamp; + + int subSteps = Math.Max(1, (int)Math.Ceiling(dt / _maxSubDt)); + double subDt = dt / subSteps; + for (int i = 0; i < subSteps; i++) + { + double springF = -_k * (_pos - _target); + double dampF = -_d * _vel; + _vel += (springF + dampF) / _m * subDt; + _pos += _vel * subDt; + } + + _apply(_pos); + + if (Math.Abs(_vel) < _restSpeed && Math.Abs(_pos - _target) < _restDelta) + { + _apply(_target); + + if (_isInfinite || _iteration < _repeat) + { + _iteration++; + // Mirror/Reverse ping-pong back to the start; Loop replays from the origin. + if (_repeatType is RepeatType.Mirror or RepeatType.Reverse) + (_from, _target) = (_target, _from); + _pos = _from; + _vel = _initialVel; + _lastTs = -1; + _startTs = timestamp; // re-arm the delay window for this repeat + _currentDelayMs = _repeatDelayMs; + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/TransformComposer.cs b/src/Bmotion/Bit.Bmotion/Engine/TransformComposer.cs new file mode 100644 index 0000000000..373f4e89af --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/TransformComposer.cs @@ -0,0 +1,60 @@ +namespace Bit.Bmotion.Engine; + +/// +/// Builds a CSS transform string from a dictionary of individual transform components. +/// Mirrors the JS buildTransformString function. +/// +internal static class TransformComposer +{ + private static readonly HashSet _transformProps = new(StringComparer.OrdinalIgnoreCase) + { + "x", "y", "z", + "rotateX", "rotateY", "rotateZ", "rotate", + "scaleX", "scaleY", "scale", + "skewX", "skewY", + "perspective", + }; + + public static bool IsTransformProp(string key) => _transformProps.Contains(key); + + /// + /// Composes a CSS transform value string from a transform-components dictionary. + /// Returns an empty string when all values are at their identity. + /// + public static string Build(Dictionary t) + { + if (t.Count == 0) return string.Empty; + + var parts = new List(8); + + if (t.TryGetValue("perspective", out double persp) && persp != 0) + parts.Add($"perspective({persp}px)"); + + double x = t.GetValueOrDefault("x"); + double y = t.GetValueOrDefault("y"); + double z = t.GetValueOrDefault("z"); + if (x != 0 || y != 0 || z != 0) + parts.Add(z != 0 + ? $"translate3d({x}px,{y}px,{z}px)" + : $"translate({x}px,{y}px)"); + + if (t.TryGetValue("scale", out double scale)) + parts.Add($"scale({scale})"); + else + { + if (t.TryGetValue("scaleX", out double sx) && sx != 1) parts.Add($"scaleX({sx})"); + if (t.TryGetValue("scaleY", out double sy) && sy != 1) parts.Add($"scaleY({sy})"); + } + + // rotateZ / rotate aliases + double rz = t.TryGetValue("rotateZ", out double rz2) ? rz2 : t.GetValueOrDefault("rotate"); + if (rz != 0) parts.Add($"rotate({rz}deg)"); + if (t.TryGetValue("rotateX", out double rx) && rx != 0) parts.Add($"rotateX({rx}deg)"); + if (t.TryGetValue("rotateY", out double ry) && ry != 0) parts.Add($"rotateY({ry}deg)"); + + if (t.TryGetValue("skewX", out double skx) && skx != 0) parts.Add($"skewX({skx}deg)"); + if (t.TryGetValue("skewY", out double sky) && sky != 0) parts.Add($"skewY({sky}deg)"); + + return string.Join(" ", parts); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs new file mode 100644 index 0000000000..391166db68 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs @@ -0,0 +1,67 @@ +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Engine; + +/// Tween (duration-based) animation driver for numeric properties. +internal sealed class TweenDriver : IAnimationDriver +{ + private readonly double _to; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly Func _easeFn; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly RepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private double _curFrom; + private double _curTo; + + public TweenDriver(double from, double to, TransitionConfig config, Action apply) + { + _curFrom = from; + _curTo = _to = to; + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _easeFn = EasingFunctions.Get(config); + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_to); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrom); return false; } + + double elapsed = timestamp - _startTime; + double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0; + double p = _easeFn(t); + double value = _curFrom + (_curTo - _curFrom) * p; + _apply(value); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + (_curFrom, _curTo) = (_curTo, _curFrom); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; +} diff --git a/src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs new file mode 100644 index 0000000000..14d1a28298 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs @@ -0,0 +1,155 @@ +using Bit.Bmotion.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bmotion.Interop; + +/// +/// Slim C# wrapper around the browser-API bridge in BitBmotion.js. +/// Only calls browser-native APIs; all animation logic lives in the C# engine. +/// +public sealed class MotionInterop : IAsyncDisposable +{ + private readonly Lazy> _moduleTask; + + public MotionInterop(IJSRuntime js) + { + _moduleTask = new Lazy>( + () => js.InvokeAsync( + "import", "./_content/Bit.Bmotion/BitBmotion.js").AsTask()); + } + + private async ValueTask Module() => await _moduleTask.Value; + + // ── rAF loop ────────────────────────────────────────────────────────────── + + /// + /// Start the JS rAF loop. The loop calls dotnetRef.invokeMethod('ComputeFrame', timestamp) + /// synchronously each tick (Blazor WASM) and applies the returned style updates. + /// + public async ValueTask StartRafLoopAsync(DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeVoidAsync("startRafLoop", dotnetRef); + + /// Stop the JS rAF loop. + public async ValueTask StopRafLoopAsync() + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("stopRafLoop"); + } + + // ── Reduced motion (accessibility) ──────────────────────────────────────── + + /// + /// Returns whether the user's OS/browser has prefers-reduced-motion: reduce set. + /// + public async ValueTask PrefersReducedMotionAsync() + => await (await Module()).InvokeAsync("prefersReducedMotion"); + + // ── Style application ───────────────────────────────────────────────────── + + /// Instantly apply a CSS styles object to a DOM element (for set() calls). + public async ValueTask ApplyStylesAsync(string elementId, object styles) + => await (await Module()).InvokeVoidAsync("applyStyles", elementId, styles); + + // ── Element registration ────────────────────────────────────────────────── + + public async ValueTask RegisterElementAsync(string elementId) + => await (await Module()).InvokeVoidAsync("registerElement", elementId); + + public async ValueTask UnregisterElementAsync(string elementId) + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("unregisterElement", elementId); + } + + // ── Gesture event listeners ─────────────────────────────────────────────── + + /// + /// Attach pointer / focus / drag event listeners to an element. + /// JS forwards raw browser events to the DotNet ref via async callbacks. + /// + public async ValueTask AttachEventListenersAsync( + string elementId, object events, DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeVoidAsync("attachEventListeners", elementId, events, dotnetRef); + + // ── Viewport observation ────────────────────────────────────────────────── + + public async ValueTask ObserveViewportAsync( + string elementId, DotNetObjectReference dotnetRef, bool once) where T : class + => await (await Module()).InvokeVoidAsync("observeViewport", elementId, dotnetRef, + new Dictionary { ["once"] = once, ["margin"] = "0px", ["threshold"] = 0.0 }); + + public async ValueTask ObserveViewportWithOptionsAsync( + string elementId, DotNetObjectReference dotnetRef, ViewportOptions options) where T : class + => await (await Module()).InvokeVoidAsync("observeViewport", elementId, dotnetRef, options.ToJsObject()); + + public async ValueTask UnobserveViewportAsync(string elementId) + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("unobserveViewport", elementId); + } + + // ── FLIP layout ─────────────────────────────────────────────────────────── + + /// Returns the element's current bounding rect (for C# FLIP delta computation). + public async ValueTask GetBoundingRectAsync(string elementId) + => await (await Module()).InvokeAsync("getBoundingRect", elementId); + + /// Run a FLIP animation via the Web Animations API. + public async ValueTask PlayWaapiFlipAsync( + string elementId, double dx, double dy, double sx, double sy, + double durationMs, string easingStr, string? finalTransform) + => await (await Module()).InvokeVoidAsync( + "playWaapiFlip", elementId, dx, dy, sx, sy, durationMs, easingStr, finalTransform); + + // ── Scroll ──────────────────────────────────────────────────────────────── + + public async ValueTask ObserveScrollAsync( + string? containerId, DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeAsync("observeScroll", containerId, dotnetRef); + + public async ValueTask UnobserveScrollAsync(string key) + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("unobserveScroll", key); + } + + public async ValueTask ObserveElementScrollAsync( + string elementId, DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeAsync("observeElementScroll", elementId, dotnetRef); + + // ── Programmatic animate() API ───────────────────────────────────────────── + + /// + /// Resolves all DOM elements matching , assigns stable IDs + /// if needed, and returns those IDs so the can address them. + /// + public async ValueTask ResolveOrRegisterBySelectorAsync(string selector) + => await (await Module()).InvokeAsync("resolveOrRegisterBySelector", selector); + + /// + /// Resolves the DOM element for , assigns a stable ID + /// if needed, and returns that ID. + /// + public async ValueTask ResolveOrRegisterByRefAsync(ElementReference elementReference) + => await (await Module()).InvokeAsync("resolveOrRegisterByRef", elementReference); + + // ── Dispose ─────────────────────────────────────────────────────────────── + + public async ValueTask DisposeAsync() + { + if (_moduleTask.IsValueCreated) + await (await Module()).DisposeAsync(); + } +} + +/// DOM bounding rect returned by getBoundingRect in JS. +public sealed class BoundingRect +{ + public double X { get; set; } + public double Y { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public double Top { get; set; } + public double Left { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs new file mode 100644 index 0000000000..39460d3866 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs @@ -0,0 +1,176 @@ +namespace Bit.Bmotion.Models; + +/// +/// Describes a set of animatable CSS / transform properties — the "what" of an animation. +/// Assign to Initial, Animate, Exit, WhileHover, WhileTap, etc. +/// +public class AnimationProps +{ + // ── Transform properties ────────────────────────────────────────────────── + public double? X { get; set; } + public double? Y { get; set; } + public double? Z { get; set; } + + public double? Scale { get; set; } + public double? ScaleX { get; set; } + public double? ScaleY { get; set; } + + public double? Rotate { get; set; } + public double? RotateX { get; set; } + public double? RotateY { get; set; } + public double? RotateZ { get; set; } + + public double? SkewX { get; set; } + public double? SkewY { get; set; } + + public double? Perspective { get; set; } + + // ── Visual properties ───────────────────────────────────────────────────── + public double? Opacity { get; set; } + + // Accept CSS color strings: #rgb, #rrggbbaa, rgb(), hsl(), named colors + public string? BackgroundColor { get; set; } + public string? Color { get; set; } + public string? BorderColor { get; set; } + public string? OutlineColor { get; set; } + public string? Fill { get; set; } + public string? Stroke { get; set; } + + // Box model (accept px values or CSS strings like "50%" or "2rem") + public string? Width { get; set; } + public string? Height { get; set; } + public string? BorderRadius { get; set; } + public string? BoxShadow { get; set; } + + // ── SVG path drawing ────────────────────────────────────────────────────── + /// 0 = invisible, 1 = fully drawn. Drives strokeDashoffset. + public double? PathLength { get; set; } + /// Offset along the path (0–1). + public double? PathOffset { get; set; } + /// Spacing between dash/gap pairs (0–1). + public double? PathSpacing { get; set; } + + // ── CSS custom properties (e.g. "--my-var") ─────────────────────────────── + /// Animate arbitrary CSS custom properties. Keys must start with "--". + public Dictionary? CssVars { get; set; } + + // ── Keyframe arrays ─────────────────────────────────────────────────────── + /// + /// Per-property keyframe arrays for multi-step animations. + /// Keys are the same as the simple property names ("x", "y", "scale", "opacity", + /// "backgroundColor", etc.). Values are double[] or string[]. + /// When a key is present here it takes precedence over the single-value property. + /// + /// + /// new AnimationProps + /// { + /// Keyframes = new() + /// { + /// ["scale"] = new double[] { 1, 1.4, 0.8, 1 }, + /// ["backgroundColor"] = new string[] { "#6c47ff", "#ff4785", "#6c47ff" } + /// } + /// } + /// + /// + /// + public Dictionary? Keyframes { get; set; } + + /// + /// Serialise to a plain JS-friendly dictionary that the interop layer understands. + /// + internal Dictionary ToJsDictionary() + { + var d = new Dictionary(); + + if (X.HasValue) d["x"] = X.Value; + if (Y.HasValue) d["y"] = Y.Value; + if (Z.HasValue) d["z"] = Z.Value; + if (Scale.HasValue) d["scale"] = Scale.Value; + if (ScaleX.HasValue) d["scaleX"] = ScaleX.Value; + if (ScaleY.HasValue) d["scaleY"] = ScaleY.Value; + if (Rotate.HasValue) d["rotate"] = Rotate.Value; + if (RotateX.HasValue) d["rotateX"] = RotateX.Value; + if (RotateY.HasValue) d["rotateY"] = RotateY.Value; + if (RotateZ.HasValue) d["rotateZ"] = RotateZ.Value; + if (SkewX.HasValue) d["skewX"] = SkewX.Value; + if (SkewY.HasValue) d["skewY"] = SkewY.Value; + if (Perspective.HasValue) d["perspective"] = Perspective.Value; + if (Opacity.HasValue) d["opacity"] = Opacity.Value; + if (BackgroundColor != null) d["backgroundColor"] = BackgroundColor; + if (Color != null) d["color"] = Color; + if (BorderColor != null) d["borderColor"] = BorderColor; + if (OutlineColor != null) d["outlineColor"] = OutlineColor; + if (Fill != null) d["fill"] = Fill; + if (Stroke != null) d["stroke"] = Stroke; + if (Width != null) d["width"] = Width; + if (Height != null) d["height"] = Height; + if (BorderRadius != null) d["borderRadius"] = BorderRadius; + if (BoxShadow != null) d["boxShadow"] = BoxShadow; + if (PathLength.HasValue) d["pathLength"] = PathLength.Value; + if (PathOffset.HasValue) d["pathOffset"] = PathOffset.Value; + if (PathSpacing.HasValue) d["pathSpacing"] = PathSpacing.Value; + + if (CssVars != null) + foreach (var kv in CssVars) + d[kv.Key] = kv.Value; + + // Keyframe arrays override single values + if (Keyframes != null) + foreach (var kv in Keyframes) + d[kv.Key] = kv.Value; + + return d; + } + + /// + /// Render these props as an inline CSS style string — used server-side to avoid a + /// flash of un-styled content before the JS interop layer initialises. + /// + internal string ToCssStyleString() + { + var sb = new System.Text.StringBuilder(); + + var transforms = new List(); + if (X.HasValue || Y.HasValue || Z.HasValue) + { + double x = X ?? 0, y = Y ?? 0, z = Z ?? 0; + if (z != 0) + transforms.Add($"translate3d({x}px,{y}px,{z}px)"); + else + transforms.Add($"translate({x}px,{y}px)"); + } + if (Scale.HasValue) transforms.Add($"scale({Scale.Value})"); + if (ScaleX.HasValue) transforms.Add($"scaleX({ScaleX.Value})"); + if (ScaleY.HasValue) transforms.Add($"scaleY({ScaleY.Value})"); + if (Rotate.HasValue || RotateZ.HasValue) + transforms.Add($"rotate({RotateZ ?? Rotate}deg)"); + if (RotateX.HasValue) transforms.Add($"rotateX({RotateX.Value}deg)"); + if (RotateY.HasValue) transforms.Add($"rotateY({RotateY.Value}deg)"); + if (SkewX.HasValue) transforms.Add($"skewX({SkewX.Value}deg)"); + if (SkewY.HasValue) transforms.Add($"skewY({SkewY.Value}deg)"); + if (Perspective.HasValue) transforms.Insert(0, $"perspective({Perspective.Value}px)"); + + if (transforms.Count > 0) sb.Append($"transform:{string.Join(" ", transforms)};"); + + if (Opacity.HasValue) sb.Append($"opacity:{Opacity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)};"); + if (BackgroundColor != null) sb.Append($"background-color:{BackgroundColor};"); + if (Color != null) sb.Append($"color:{Color};"); + if (BorderColor != null) sb.Append($"border-color:{BorderColor};"); + if (Fill != null) sb.Append($"fill:{Fill};"); + if (Stroke != null) sb.Append($"stroke:{Stroke};"); + if (Width != null) sb.Append($"width:{Width};"); + if (Height != null) sb.Append($"height:{Height};"); + if (BorderRadius != null) sb.Append($"border-radius:{BorderRadius};"); + if (PathLength.HasValue) + { + double clamped = Math.Max(0, Math.Min(1, PathLength.Value)); + sb.Append($"stroke-dasharray:1 1;stroke-dashoffset:{(1 - clamped).ToString("G6", System.Globalization.CultureInfo.InvariantCulture)};"); + } + + if (CssVars != null) + foreach (var kv in CssVars) + sb.Append($"{kv.Key}:{kv.Value};"); + + return sb.ToString(); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs b/src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs new file mode 100644 index 0000000000..11b827a3e9 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs @@ -0,0 +1,31 @@ +namespace Bit.Bmotion.Models; + +/// +/// Union type for animation target parameters (Initial, Animate, Exit, WhileHover, …). +/// Can be implicitly constructed from , a variant name string, +/// or false to disable the target entirely. +/// +public sealed class AnimationTarget +{ + /// Direct set of animation properties. + public AnimationProps? Props { get; private init; } + + /// Name of a variant defined in the nearest Motion ancestor's Variants dictionary. + public string? Variant { get; private init; } + + /// When true this target is explicitly disabled (e.g. Initial="false"). + public bool IsDisabled { get; private init; } + + public bool HasProps => Props != null; + public bool IsVariant => Variant != null; + + // ── Implicit conversions ────────────────────────────────────────────────── + public static implicit operator AnimationTarget(AnimationProps props) + => new() { Props = props }; + + public static implicit operator AnimationTarget(string variant) + => new() { Variant = variant }; + + public static implicit operator AnimationTarget(bool value) + => value ? new() { Props = new AnimationProps() } : new() { IsDisabled = true }; +} diff --git a/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs b/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs new file mode 100644 index 0000000000..0f992317f5 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs @@ -0,0 +1,97 @@ +namespace Bit.Bmotion.Models; + +/// +/// Options for the drag gesture on a Motion element. +/// +public class DragOptions +{ + /// Restrict drag to a single axis. Null = both axes. + public DragAxis Axis { get; set; } = DragAxis.Both; + + /// + /// Constraint bounds (in px relative to the element's resting position). + /// Null = unconstrained. + /// + public DragConstraints? Constraints { get; set; } + + /// + /// Elasticity when the drag exceeds constraints (0 = rigid, 1 = fully elastic). + /// Default: 0.35. + /// + public double Elastic { get; set; } = 0.35; + + /// + /// Whether to apply momentum / inertia after releasing. Default: true. + /// + public bool Momentum { get; set; } = true; + + /// + /// Transition applied when snapping back to constraints after release. + /// Defaults to a spring. + /// + public TransitionConfig? SnapTransition { get; set; } + + /// + /// If true, the draggable element will spring back to its center (origin) when released. + /// Default: false. + /// + public bool SnapToOrigin { get; set; } + + /// + /// Locks drag to the dominant movement axis once detected. + /// For example, moving mostly horizontally will lock drag to x only. + /// Default: false. + /// + public bool DirectionLock { get; set; } + + internal object ToJsObject() + { + var d = new Dictionary + { + ["drag"] = true, + ["dragAxis"] = Axis == DragAxis.Both ? null : Axis.ToString().ToLowerInvariant(), + ["dragElastic"] = Elastic, + ["dragMomentum"] = Momentum, + }; + + if (Constraints != null) + d["dragConstraints"] = Constraints.ToJsObject(); + + if (SnapTransition != null) + d["dragSnapTransition"] = SnapTransition.ToJsObject(); + + if (SnapToOrigin) d["dragSnapToOrigin"] = true; + if (DirectionLock) d["dragDirectionLock"] = true; + + return d; + } +} + +public class DragConstraints +{ + public double? Left { get; set; } + public double? Right { get; set; } + public double? Top { get; set; } + public double? Bottom { get; set; } + + public static DragConstraints Horizontal(double left, double right) + => new() { Left = left, Right = right }; + + public static DragConstraints Vertical(double top, double bottom) + => new() { Top = top, Bottom = bottom }; + + public static DragConstraints Box(double left, double right, double top, double bottom) + => new() { Left = left, Right = right, Top = top, Bottom = bottom }; + + internal object ToJsObject() + { + var d = new Dictionary(); + if (Left.HasValue) d["left"] = Left.Value; + if (Right.HasValue) d["right"] = Right.Value; + if (Top.HasValue) d["top"] = Top.Value; + if (Bottom.HasValue) d["bottom"] = Bottom.Value; + return d; + } +} + +public enum DragAxis { Both, X, Y } diff --git a/src/Bmotion/Bit.Bmotion/Models/LayoutOptions.cs b/src/Bmotion/Bit.Bmotion/Models/LayoutOptions.cs new file mode 100644 index 0000000000..a0a7f3b441 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/LayoutOptions.cs @@ -0,0 +1,25 @@ +namespace Bit.Bmotion.Models; + +/// +/// Options for the Layout animation feature on a Motion component. +/// When is true a FLIP animation plays whenever the element +/// changes its position or size in the document layout. +/// +public class LayoutOptions +{ + /// Enable automatic layout animations. Default: false. + public bool Enabled { get; set; } = true; + + /// + /// Unique identifier used for shared-element (cross-component) layout transitions. + /// Two Motion components with the same LayoutId will animate between each other + /// when one mounts and the other unmounts. + /// + public string? LayoutId { get; set; } + + /// + /// Transition to use for the layout animation. + /// Defaults to a snappy spring. + /// + public TransitionConfig? Transition { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs b/src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs new file mode 100644 index 0000000000..8d67d92442 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs @@ -0,0 +1,33 @@ +namespace Bit.Bmotion.Models; + +/// +/// A named set of animation states (variants) that can be referenced by name on +/// any Motion component. Children automatically inherit the active variant name +/// unless they define their own. +/// +public class MotionVariants +{ + private readonly Dictionary _variants = new(StringComparer.OrdinalIgnoreCase); + + public MotionVariants Add(string name, AnimationProps props) + { + _variants[name] = props; + return this; + } + + public AnimationProps? Get(string name) + => _variants.TryGetValue(name, out var v) ? v : null; + + public bool Contains(string name) => _variants.ContainsKey(name); + + public AnimationProps? this[string name] => Get(name); + + // ── Builder shorthand ───────────────────────────────────────────────────── + public static MotionVariants Create(params (string name, AnimationProps props)[] entries) + { + var mv = new MotionVariants(); + foreach (var (name, props) in entries) + mv.Add(name, props); + return mv; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/PanInfo.cs b/src/Bmotion/Bit.Bmotion/Models/PanInfo.cs new file mode 100644 index 0000000000..e7da64a45c --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/PanInfo.cs @@ -0,0 +1,27 @@ +namespace Bit.Bmotion.Models; + +/// +/// Information about a pan gesture provided to OnPan callbacks. +/// Matches the Framer Motion pan event info shape. +/// +public class PanInfo +{ + /// Current pointer position relative to the document. + public PointInfo Point { get; set; } = new(); + + /// Distance moved since the last event. + public PointInfo Delta { get; set; } = new(); + + /// Total distance moved since the pan gesture started. + public PointInfo Offset { get; set; } = new(); + + /// Current velocity of the pointer (pixels per second). + public PointInfo Velocity { get; set; } = new(); +} + +/// A 2-D point with and components. +public class PointInfo +{ + public double X { get; set; } + public double Y { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs b/src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs new file mode 100644 index 0000000000..cbf38fcfaf --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs @@ -0,0 +1,36 @@ +namespace Bit.Bmotion.Models; + +/// Data returned by the on each scroll event. +public class ScrollInfo +{ + /// Horizontal scroll offset in pixels. + public double ScrollX { get; init; } + + /// Vertical scroll offset in pixels. + public double ScrollY { get; init; } + + /// Horizontal scroll progress 0–1. + public double ProgressX { get; init; } + + /// Vertical scroll progress 0–1. + public double ProgressY { get; init; } + + public double ScrollWidth { get; init; } + public double ScrollHeight { get; init; } + public double ClientWidth { get; init; } + public double ClientHeight { get; init; } +} + +/// +/// Describes how an element's scroll position maps to a progress value. +/// Used with . +/// +public class ScrollOffset +{ + /// + /// Two-item array [startOffset, endOffset] where each can be a pixel value, + /// a percentage string like "50%", or a named edge like "start center". + /// Leave null to use default ("start end" → "end start"). + /// + public string[]? Offset { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs new file mode 100644 index 0000000000..3afa88deed --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs @@ -0,0 +1,274 @@ +namespace Bit.Bmotion.Models; + +/// Controls how a value transitions from one state to another. +public class TransitionConfig +{ + // ── Type ───────────────────────────────────────────────────────────────── + /// Animation driver: Tween, Spring, or Inertia. Default: Tween. + public TransitionType Type { get; set; } = TransitionType.Tween; + + // ── Tween ───────────────────────────────────────────────────────────────── + /// Duration in seconds. Default: 0.3. + public double Duration { get; set; } = 0.3; + + /// Delay before animation starts, in seconds. Default: 0. + public double Delay { get; set; } = 0; + + /// Named easing preset. See . Default: EaseOut. + public Easing Ease { get; set; } = Easing.EaseOut; + + /// + /// Custom cubic-bezier as [x1, y1, x2, y2]. Overrides when set. + /// + public double[]? EaseCubicBezier { get; set; } + + // ── Repeat ──────────────────────────────────────────────────────────────── + /// Number of times to repeat. Set to int.MaxValue for infinite. + public int Repeat { get; set; } = 0; + + /// How to repeat: Loop, Mirror (ping-pong), or Reverse. + public RepeatType RepeatType { get; set; } = RepeatType.Loop; + + /// Delay between repetitions, in seconds. + public double RepeatDelay { get; set; } = 0; + + // ── Keyframes ───────────────────────────────────────────────────────────── + /// + /// Progress offsets (0–1) for each keyframe value. Length must match value array. + /// If omitted the frames are evenly distributed. + /// + public double[]? Times { get; set; } + + // ── Spring ──────────────────────────────────────────────────────────────── + /// Spring stiffness (N/m). Higher = snappier. Default: 100. + public double Stiffness { get; set; } = 100; + + /// Damping coefficient. Higher = less oscillation. Default: 10. + public double Damping { get; set; } = 10; + + /// Virtual mass. Higher = slower acceleration. Default: 1. + public double Mass { get; set; } = 1; + + /// Initial velocity for the spring (units/s). Default: 0. + public double Velocity { get; set; } = 0; + + /// Minimum speed (units/s) considered at rest. Default: 0.01. + public double RestSpeed { get; set; } = 0.01; + + /// Minimum distance from target considered at rest. Default: 0.01. + public double RestDelta { get; set; } = 0.01; + + /// + /// Bounciness of a duration-based spring (0 = critically damped, 1 = very bouncy). + /// When set together with or , + /// stiffness and damping are derived automatically (overriding their values). + /// + public double? Bounce { get; set; } + + /// + /// The visual time (in seconds) the spring will take to appear to reach its target. + /// Works together with for intuitive spring configuration. + /// Overrides when computing spring parameters. + /// + public double? VisualDuration { get; set; } + + // ── Inertia ─────────────────────────────────────────────────────────────── + /// Velocity at the start of deceleration. Default: 0. + public double InertiaVelocity { get; set; } = 0; + + /// Exponential decay time constant in ms. Default: 700. + public double TimeConstant { get; set; } = 700; + + /// Multiplier for the projected distance. Default: 0.8. + public double Power { get; set; } = 0.8; + + /// Minimum distance from target that counts as at rest. Default: 0.5. + public double InertiaRestDelta { get; set; } = 0.5; + + /// Optional lower bound for the inertia target. + public double? InertiaMin { get; set; } + + /// Optional upper bound for the inertia target. + public double? InertiaMax { get; set; } + + // ── Orchestration (for Variants) ────────────────────────────────────────── + /// + /// Seconds to stagger each child's animation start. Works in Variant transitions. + /// + public double? StaggerChildren { get; set; } + + /// Seconds to delay the first child's animation start. + public double? DelayChildren { get; set; } + + /// Order relative to parent: Default (in parallel), BeforeChildren, AfterChildren. + public WhenType When { get; set; } = WhenType.Default; + + // ── Per-property overrides ──────────────────────────────────────────────── + /// + /// Override transition for specific properties, e.g. + /// Properties = new { ["opacity"] = new TransitionConfig { Duration = 0.1 } } + /// + public Dictionary? Properties { get; set; } + + /// + /// Called on every animation frame with the latest interpolated value. + /// Supported for single-value numeric animations. + /// + public Action? OnUpdate { get; set; } + + // ── Helpers ─────────────────────────────────────────────────────────────── + internal object ToJsObject() + { + var d = new Dictionary + { + ["type"] = Type.ToString().ToLowerInvariant(), + ["duration"] = Duration, + ["delay"] = Delay, + ["ease"] = EaseCubicBezier != null ? (object)EaseCubicBezier : EasingToJs(Ease), + ["repeat"] = Repeat == int.MaxValue ? "Infinity" : (object)Repeat, + ["repeatType"] = RepeatType.ToString().ToLowerInvariant(), + ["repeatDelay"] = RepeatDelay, + ["stiffness"] = Stiffness, + ["damping"] = Damping, + ["mass"] = Mass, + ["velocity"] = Velocity, + ["restSpeed"] = RestSpeed, + ["restDelta"] = RestDelta, + ["inertiaVelocity"] = InertiaVelocity, + ["timeConstant"] = TimeConstant, + ["power"] = Power, + ["inertiaRestDelta"] = InertiaRestDelta, + }; + + if (Times != null) d["times"] = Times; + if (StaggerChildren.HasValue) d["staggerChildren"] = StaggerChildren.Value; + if (DelayChildren.HasValue) d["delayChildren"] = DelayChildren.Value; + if (When != WhenType.Default) d["when"] = When.ToString().ToLowerInvariant(); + if (InertiaMin.HasValue) d["inertiaMin"] = InertiaMin.Value; + if (InertiaMax.HasValue) d["inertiaMax"] = InertiaMax.Value; + + if (Properties != null) + { + var props = new Dictionary(); + foreach (var kv in Properties) + props[kv.Key] = kv.Value.ToJsObject(); + d["properties"] = props; + } + + return d; + } + + private static string EasingToJs(Easing e) => e switch + { + Easing.Linear => "linear", + Easing.EaseIn => "easeIn", + Easing.EaseOut => "easeOut", + Easing.EaseInOut => "easeInOut", + Easing.CircIn => "circIn", + Easing.CircOut => "circOut", + Easing.CircInOut => "circInOut", + Easing.BackIn => "backIn", + Easing.BackOut => "backOut", + Easing.BackInOut => "backInOut", + Easing.Anticipate => "anticipate", + _ => "easeOut" + }; + + /// + /// Creates a deep copy of this configuration. Used internally when the library + /// needs to derive a variant of a transition (e.g. applying a global + /// scale or a stagger delay) + /// without mutating or partially losing the original's fields. + /// + public TransitionConfig Clone() => new() + { + Type = Type, + Duration = Duration, + Delay = Delay, + Ease = Ease, + EaseCubicBezier = EaseCubicBezier is null ? null : (double[])EaseCubicBezier.Clone(), + Repeat = Repeat, + RepeatType = RepeatType, + RepeatDelay = RepeatDelay, + Times = Times is null ? null : (double[])Times.Clone(), + Stiffness = Stiffness, + Damping = Damping, + Mass = Mass, + Velocity = Velocity, + RestSpeed = RestSpeed, + RestDelta = RestDelta, + Bounce = Bounce, + VisualDuration = VisualDuration, + InertiaVelocity = InertiaVelocity, + TimeConstant = TimeConstant, + Power = Power, + InertiaRestDelta = InertiaRestDelta, + InertiaMin = InertiaMin, + InertiaMax = InertiaMax, + StaggerChildren = StaggerChildren, + DelayChildren = DelayChildren, + When = When, + Properties = Properties, + OnUpdate = OnUpdate, + }; + + // ── Factory helpers ─────────────────────────────────────────────────────── + public static TransitionConfig Spring(double stiffness = 100, double damping = 10, double mass = 1) + => new() { Type = TransitionType.Spring, Stiffness = stiffness, Damping = damping, Mass = mass }; + + /// + /// Duration-based spring using intuitive (0 = no bounce, 1 = very bouncy) + /// and parameters. Stiffness and damping are derived automatically. + /// + public static TransitionConfig BounceSpring(double duration = 0.5, double bounce = 0.25, double mass = 1) + { + var (stiffness, damping) = SpringFromBounce(duration, bounce, mass); + return new() + { + Type = TransitionType.Spring, + Duration = duration, + Bounce = bounce, + VisualDuration = duration, + Stiffness = stiffness, + Damping = damping, + Mass = mass, + }; + } + + public static TransitionConfig Tween(double duration = 0.3, Easing ease = Easing.EaseOut) + => new() { Type = TransitionType.Tween, Duration = duration, Ease = ease }; + + public static TransitionConfig Inertia(double velocity = 0, double timeConstant = 700) + => new() { Type = TransitionType.Inertia, InertiaVelocity = velocity, TimeConstant = timeConstant }; + + /// + /// Derives (stiffness, damping) from Framer-Motion-compatible + /// (0–1) and parameters. + /// + internal static (double stiffness, double damping) SpringFromBounce( + double visualDuration, double bounce, double mass = 1) + { + double b = Math.Clamp(bounce, 0.0, 1.0); + double omega0 = (2.0 * Math.PI) / Math.Max(visualDuration, 0.001); + // damping ratio: 0 → fully elastic (bounce=1), 1 → critically damped (bounce=0) + double zeta = b < 0.05 ? 1.0 : Math.Sqrt(1.0 - Math.Pow(b, 2.0 / 3.0)); + return (Math.Max(omega0 * omega0 * mass, 0.001), Math.Max(2.0 * zeta * omega0 * mass, 0.001)); + } +} + +// ── Enumerations ────────────────────────────────────────────────────────────── + +public enum TransitionType { Tween, Spring, Inertia, Keyframes } + +public enum Easing +{ + Linear, + EaseIn, EaseOut, EaseInOut, + CircIn, CircOut, CircInOut, + BackIn, BackOut, BackInOut, + Anticipate +} + +public enum RepeatType { Loop, Mirror, Reverse } + +public enum WhenType { Default, BeforeChildren, AfterChildren } diff --git a/src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs b/src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs new file mode 100644 index 0000000000..2acba72d27 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs @@ -0,0 +1,50 @@ +namespace Bit.Bmotion.Models; + +/// +/// Options that control how a element is tracked within the viewport +/// for WhileInView and OnViewportEnter/OnViewportLeave animations. +/// +public class ViewportOptions +{ + /// + /// If true, once the element enters the viewport the animation will not + /// reverse when the element leaves. Default: false. + /// + public bool Once { get; set; } + + /// + /// A CSS margin string added to the viewport detection area, e.g. "0px -20px 0px 100px". + /// Supports the same format as IntersectionObserver.rootMargin. + /// Default: "0px". + /// + public string Margin { get; set; } = "0px"; + + /// + /// How much of the element must be visible to be considered "in view". + /// + /// "some" (default) — any part visible. + /// "all" — fully visible. + /// A number between 0 and 1 for exact threshold. + /// + /// + public string Amount { get; set; } = "some"; + + internal object ToJsObject() + { + double threshold = Amount switch + { + "some" => 0.0, + "all" => 1.0, + _ => double.TryParse(Amount, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var v) + ? Math.Clamp(v, 0, 1) : 0.0, + }; + + return new Dictionary + { + ["once"] = Once, + ["margin"] = Margin, + ["threshold"] = threshold, + }; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/AnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/AnimationController.cs new file mode 100644 index 0000000000..d11322aeed --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/AnimationController.cs @@ -0,0 +1,49 @@ +using Bit.Bmotion.Engine; +using Bit.Bmotion.Models; + +namespace Bit.Bmotion.Services; + +/// +/// Programmatic animation controller. +/// Analogous to Framer Motion's useAnimate(). +/// Obtain via DI (@inject AnimationController) and bind to an element ID. +/// All animation math runs in the C# . +/// +public sealed class AnimationController +{ + private readonly AnimationEngine _engine; + private string? _elementId; + + public AnimationController(AnimationEngine engine) => _engine = engine; + + /// Bind by element ID. + public void BindTo(string elementId) => _elementId = elementId; + + /// Animate the bound element to the given props (fire-and-forget). + public async ValueTask AnimateAsync(AnimationProps props, TransitionConfig? transition = null) + { + if (_elementId == null) return; + await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition); + } + + /// Animate and await completion. + public async ValueTask AnimateAwaitAsync(AnimationProps props, TransitionConfig? transition = null) + { + if (_elementId == null) return; + await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition); + } + + /// Instantly set props without animation. + public void Set(AnimationProps props) + { + if (_elementId == null) return; + _engine.SetInstant(_elementId, props.ToJsDictionary()); + } + + /// Stop animations on the bound element. + public void Stop(params string[] properties) + { + if (_elementId == null) return; + _engine.Stop(_elementId, properties.Length > 0 ? properties : null); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs new file mode 100644 index 0000000000..5dcea48437 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs @@ -0,0 +1,48 @@ +using System.Runtime.CompilerServices; +using Bit.Bmotion.Engine; + +namespace Bit.Bmotion.Services; + +/// +/// Controls for an in-flight programmatic animation started by +/// . +/// The object is directly awaitable — await controls waits for the animation to complete. +/// +public sealed class AnimationControls +{ + private readonly IReadOnlyList _elementIds; + private readonly AnimationEngine _engine; + private readonly Task _completion; + + internal AnimationControls(IReadOnlyList elementIds, AnimationEngine engine, Task completion) + { + _elementIds = elementIds; + _engine = engine; + _completion = completion; + } + + /// + /// Immediately cancel all running animations on the target elements. + /// Elements snap to their current (intermediate) positions. + /// + public void Stop() + { + foreach (var id in _elementIds) + _engine.Stop(id, null); + } + + /// + /// Cancel all running animations and snap elements to their target (end) values. + /// + public void Complete() + { + foreach (var id in _elementIds) + _engine.Stop(id, null); + } + + /// A that resolves when all animations finish naturally. + public Task WhenCompleteAsync() => _completion; + + /// Makes directly awaitable. + public TaskAwaiter GetAwaiter() => _completion.GetAwaiter(); +} diff --git a/src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs new file mode 100644 index 0000000000..471208a78e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs @@ -0,0 +1,104 @@ +using Bit.Bmotion.Engine; +using Bit.Bmotion.Interop; +using Bit.Bmotion.Models; +using Microsoft.AspNetCore.Components; + +namespace Bit.Bmotion.Services; + +/// +/// Provides a method-based animation API analogous to the animate() function in +/// motion.dev. +/// +/// Elements are identified by a CSS selector string or a Blazor . +/// They do not need to be wrapped in a <Motion> component. +/// +/// +/// +/// +/// // By CSS selector +/// var controls = await Motion.AnimateAsync(".box", new AnimationProps { X = 100, Opacity = 1 }); +/// await controls; // wait for completion +/// +/// // By ElementReference captured via @ref +/// var controls = await Motion.AnimateAsync(myRef, new AnimationProps { Scale = 1.2 }, +/// TransitionConfig.Spring()); +/// controls.Stop(); // cancel early +/// +/// +public sealed class MotionAnimateService +{ + private readonly AnimationEngine _engine; + private readonly MotionInterop _interop; + + public MotionAnimateService(AnimationEngine engine, MotionInterop interop) + { + _engine = engine; + _interop = interop; + } + + /// + /// Animate all DOM elements matching to + /// . + /// + /// + /// A CSS selector string, e.g. ".card", "#hero", or "div.item". + /// Multiple matching elements are animated simultaneously. + /// + /// Target animation properties. + /// + /// Optional transition configuration (easing, duration, spring parameters, etc.). + /// Falls back to the global default when omitted. + /// + /// + /// An that can be awaited or stopped early. + /// + public async ValueTask AnimateAsync( + string selector, + AnimationProps keyframes, + TransitionConfig? transition = null) + { + var ids = await _interop.ResolveOrRegisterBySelectorAsync(selector); + return StartAnimations(ids, keyframes, transition); + } + + /// + /// Animate the element captured by to + /// . + /// + /// + /// A Blazor obtained via @ref on any HTML element. + /// + /// Target animation properties. + /// Optional transition configuration. + /// + /// An that can be awaited or stopped early. + /// + public async ValueTask AnimateAsync( + ElementReference elementReference, + AnimationProps keyframes, + TransitionConfig? transition = null) + { + var id = await _interop.ResolveOrRegisterByRefAsync(elementReference); + return StartAnimations([id], keyframes, transition); + } + + // ──────────────────────────────────────────────────────────────────────────── + + private AnimationControls StartAnimations( + string[] elementIds, + AnimationProps keyframes, + TransitionConfig? transition) + { + var values = keyframes.ToJsDictionary(); + + foreach (var id in elementIds) + _engine.RegisterElement(id); + + // Start all animations concurrently; collect their completion tasks. + var completionTasks = elementIds + .Select(id => _engine.AnimateToAwaitAsync(id, values, transition).AsTask()) + .ToArray(); + + return new AnimationControls(elementIds, _engine, Task.WhenAll(completionTasks)); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/MotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/MotionValue.cs new file mode 100644 index 0000000000..11eea545cb --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/MotionValue.cs @@ -0,0 +1,104 @@ +namespace Bit.Bmotion.Services; + +/// +/// A reactive numeric value whose changes can be observed and linked to animations. +/// Analogous to Framer Motion's MotionValue<T>. +/// Purely C# — no JS synchronisation required. +/// +public class MotionValue : IDisposable where T : struct +{ + private readonly string _id; + private T _value; + private readonly List> _subscribers = new(); + + internal MotionValue(string id, T initial) + { + _id = id; + _value = initial; + } + + // ── Value access ────────────────────────────────────────────────────────── + + public T Value + { + get => _value; + set => _ = SetAsync(value); + } + + /// Update the value and notify all subscribers. + public async Task SetAsync(T value) + { + _value = value; + foreach (var sub in _subscribers) + await sub(value); + } + + // ── Subscriptions ───────────────────────────────────────────────────────── + + /// Subscribe to value changes. Returns an unsubscribe action. + public IDisposable Subscribe(Func callback) + { + _subscribers.Add(callback); + return new Subscription(() => _subscribers.Remove(callback)); + } + + /// Synchronous convenience overload. + public IDisposable Subscribe(Action callback) + => Subscribe(v => { callback(v); return Task.CompletedTask; }); + + // ── Transforms ──────────────────────────────────────────────────────────── + + /// + /// Create a derived MotionValue that applies a transformation function. + /// Analogous to Framer Motion's useTransform. + /// + public MotionValue Transform(Func fn) where TOut : struct + { + var derived = new MotionValue($"{_id}_t", fn(_value)); + Subscribe(async v => await derived.SetAsync(fn(v))); + return derived; + } + + /// + /// Map from an input range to an output range using linear interpolation. + /// + public MotionValue Transform(double[] inputRange, double[] outputRange) + { + if (inputRange.Length != outputRange.Length) + throw new ArgumentException("inputRange and outputRange must have the same length."); + + double Map(T v) + { + double x = Convert.ToDouble(v); + for (int i = 0; i < inputRange.Length - 1; i++) + { + if (x >= inputRange[i] && x <= inputRange[i + 1]) + { + double t = (x - inputRange[i]) / (inputRange[i + 1] - inputRange[i]); + return outputRange[i] + t * (outputRange[i + 1] - outputRange[i]); + } + } + return x < inputRange[0] ? outputRange[0] : outputRange[^1]; + } + + var derived = new MotionValue($"{_id}_tr", Map(_value)); + Subscribe(async v => await derived.SetAsync(Map(v))); + return derived; + } + + public void Dispose() => _subscribers.Clear(); + + private sealed class Subscription : IDisposable + { + private readonly Action _dispose; + public Subscription(Action dispose) => _dispose = dispose; + public void Dispose() => _dispose(); + } +} + +/// Factory helper for creating MotionValues. +public static class MotionValueFactory +{ + public static MotionValue Create(T initial) where T : struct + => new($"mv_{Guid.NewGuid():N}", initial); +} diff --git a/src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs new file mode 100644 index 0000000000..d68390a02e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs @@ -0,0 +1,87 @@ +using Bit.Bmotion.Interop; +using Bit.Bmotion.Models; +using Microsoft.JSInterop; + +namespace Bit.Bmotion.Services; + +/// +/// Tracks scroll progress (0–1) for a container element or the window. +/// Analogous to Framer Motion's useScroll. +/// +/// Usage: +/// +/// @inject ScrollTracker Scroll +/// +/// protected override async Task OnAfterRenderAsync(bool firstRender) +/// { +/// if (firstRender) await Scroll.ObserveAsync(null, info => scrollY = info.ProgressY); +/// } +/// +/// +public sealed class ScrollTracker : IAsyncDisposable +{ + private readonly MotionInterop _interop; + private readonly List _subscriptionKeys = new(); + private readonly DotNetObjectReference _dotnet; + + private Func? _onScroll; + private bool _disposed; + + public ScrollTracker(MotionInterop interop) + { + _interop = interop; + _dotnet = DotNetObjectReference.Create(this); + } + /// Horizontal scroll progress 0–1. + public double ProgressX { get; private set; } + + /// Vertical scroll progress 0–1. + public double ProgressY { get; private set; } + + /// Raw pixel scroll offset. + public double ScrollX { get; private set; } + public double ScrollY { get; private set; } + + /// + /// Start observing scroll events on the given container (or the window if null). + /// + /// HTML element id, or null for window. + /// Callback invoked on every scroll event. + public async Task ObserveAsync(string? containerId, Func onChange) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _onScroll = onChange; + var key = await _interop.ObserveScrollAsync(containerId, _dotnet!); + if (key != null) _subscriptionKeys.Add(key); + } + + /// Synchronous overload. + public Task ObserveAsync(string? containerId, Action onChange) + => ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; }); + + // ── JS → C# callback ───────────────────────────────────────────────────── + + [JSInvokable] + public async Task OnScroll(ScrollInfo info) + { + ProgressX = info.ProgressX; + ProgressY = info.ProgressY; + ScrollX = info.ScrollX; + ScrollY = info.ScrollY; + if (_onScroll != null) + await _onScroll(info); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + foreach (var key in _subscriptionKeys) + await _interop.UnobserveScrollAsync(key); + _subscriptionKeys.Clear(); + _onScroll = null; + _dotnet?.Dispose(); + // Note: MotionInterop itself is DI-scoped and disposed by the DI container + } +} diff --git a/src/Bmotion/Bit.Bmotion/_Imports.razor b/src/Bmotion/Bit.Bmotion/_Imports.razor new file mode 100644 index 0000000000..d32c668983 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/_Imports.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components.Web +@using Bit.Bmotion +@using Bit.Bmotion.Components +@using Bit.Bmotion.Models +@using Bit.Bmotion.Services +@using Bit.Bmotion.Context diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js new file mode 100644 index 0000000000..cd5de584fa --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js @@ -0,0 +1,479 @@ +/** + * BitBmotion.js — slim browser-API bridge + * + * All animation math (spring, tween, inertia, keyframes, easing, colour + * interpolation, gesture state, transform composition) now lives in the + * C# AnimationEngine / ElementAnimationState classes running as WebAssembly. + * + * This file only touches browser-native APIs: + * requestAnimationFrame drives the C# animation engine each tick + * element.style applies CSS updates returned by ComputeFrame + * Pointer / Focus events gesture input forwarded to the C# component + * IntersectionObserver viewport visibility forwarded to C# + * Scroll events scroll progress forwarded to C# + * getBoundingClientRect FLIP layout snapshot + * Web Animations API FLIP playback + */ + +// +// rAF loop C# ComputeFrame is called synchronously each tick (Blazor WASM) +// + +let _rafId = null; +let _animEngine = null; + +export function startRafLoop(dotnetRef) { + _animEngine = dotnetRef; + if (_rafId !== null) cancelAnimationFrame(_rafId); + _rafId = requestAnimationFrame(_tick); +} + +export function stopRafLoop() { + if (_rafId !== null) { cancelAnimationFrame(_rafId); _rafId = null; } + _animEngine = null; +} + +function _tick(timestamp) { + if (!_animEngine) return; + // invokeMethod is synchronous in Blazor WASM C# does all animation math here + const updates = _animEngine.invokeMethod('ComputeFrame', timestamp); + if (updates) { + for (const elementId in updates) { + const el = document.getElementById(elementId); + if (!el) continue; + _applyStyles(el, updates[elementId]); + } + } + _rafId = requestAnimationFrame(_tick); +} + +// +// Style helpers +// + +function _applyStyles(el, styles) { + for (const prop in styles) { + if (prop.startsWith('--')) el.style.setProperty(prop, styles[prop]); + else el.style[prop] = styles[prop]; + } +} + +/** Apply a styles object to an element by ID (used for instant set() calls). */ +export function applyStyles(elementId, styles) { + const el = document.getElementById(elementId); + if (el) _applyStyles(el, styles); +} + +/** Read a single computed style value. */ +export function getComputedStyleValue(elementId, prop) { + const el = document.getElementById(elementId); + return el ? (getComputedStyle(el)[prop] ?? '') : ''; +} + +// +// Accessibility — prefers-reduced-motion +// + +/** Returns true when the user has requested reduced motion at the OS/browser level. */ +export function prefersReducedMotion() { + return typeof matchMedia === 'function' && + matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +// +// Element registration +// + +const _eventCleanup = new Map(); // elementId Array<() => void> + +export function registerElement(elementId) { + const el = document.getElementById(elementId); + if (el) el.setAttribute('data-bmid', elementId); +} + +// +// Programmatic animate() API — resolve elements by CSS selector or ElementReference +// Assigns a stable id + data-bmid so the engine can address them via getElementById. +// + +let _programmaticSeq = 0; + +function _ensureElementId(el) { + const existing = el.getAttribute('data-bmid'); + if (existing) return existing; + const id = el.id || ('bm-p' + (++_programmaticSeq)); + el.id = id; + el.setAttribute('data-bmid', id); + return id; +} + +/** Resolve all elements matching a CSS selector and return their element IDs. */ +export function resolveOrRegisterBySelector(selector) { + try { + return Array.from(document.querySelectorAll(selector)).map(el => _ensureElementId(el)); + } catch { + return []; + } +} + +/** Resolve the element for a Blazor ElementReference and return its element ID. */ +export function resolveOrRegisterByRef(element) { + return _ensureElementId(element); +} + +export function unregisterElement(elementId) { + const el = document.getElementById(elementId); + if (el) el.removeAttribute('data-bmid'); + _runCleanup(elementId); + if (_vpObserver && el) _vpObserver.unobserve(el); + _vpRefs.delete(elementId); +} + +function _runCleanup(elementId) { + const fns = _eventCleanup.get(elementId); + if (fns) { fns.forEach(fn => fn()); _eventCleanup.delete(elementId); } +} + +// +// Gesture event listeners (hover / tap / focus / drag) +// C# handles all state-machine logic; JS only forwards raw browser events. +// + +/** + * Attach event listeners to an element. + * @param {string} elementId + * @param {{ hover?: bool, tap?: bool, focus?: bool, drag?: bool, + * dragAxis?: string, dragConstraints?: object, + * dragElastic?: number }} events + * @param dotnetRef DotNetObjectReference + */ +export function attachEventListeners(elementId, events, dotnetRef) { + const el = document.getElementById(elementId); + if (!el) return; + _runCleanup(elementId); + const cleanups = []; + _eventCleanup.set(elementId, cleanups); + + // Hover + if (events.hover) { + const onEnter = () => dotnetRef.invokeMethodAsync('OnPointerEnter'); + const onLeave = () => dotnetRef.invokeMethodAsync('OnPointerLeave'); + el.addEventListener('pointerenter', onEnter); + el.addEventListener('pointerleave', onLeave); + cleanups.push(() => { el.removeEventListener('pointerenter', onEnter); el.removeEventListener('pointerleave', onLeave); }); + } + + // Tap + if (events.tap) { + let pressing = false; + const onDown = () => { pressing = true; dotnetRef.invokeMethodAsync('OnPointerDown'); }; + const onUp = (e) => { + if (!pressing) return; pressing = false; + dotnetRef.invokeMethodAsync('OnPointerUp', el.contains(e.target) || el === e.target); + }; + const onCancel = () => { if (!pressing) return; pressing = false; dotnetRef.invokeMethodAsync('OnPointerCancel'); }; + el.addEventListener('pointerdown', onDown); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onCancel); + cleanups.push(() => { + el.removeEventListener('pointerdown', onDown); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onCancel); + }); + } + + // Focus + if (events.focus) { + const onIn = () => dotnetRef.invokeMethodAsync('OnFocusIn'); + const onOut = () => dotnetRef.invokeMethodAsync('OnFocusOut'); + el.addEventListener('focusin', onIn); + el.addEventListener('focusout', onOut); + cleanups.push(() => { el.removeEventListener('focusin', onIn); el.removeEventListener('focusout', onOut); }); + } + + // Pan (detects movement ≥ 3px without moving the element) + if (events.pan) { + _attachPan(el, dotnetRef, cleanups); + } + + // Drag + if (events.drag) { + _attachDrag(elementId, el, events, dotnetRef, cleanups); + } +} + +function _attachPan(el, dotnetRef, cleanups) { + const PAN_THRESHOLD = 3; // pixels before pan is detected + let panning = false; + let startX, startY, lastX, lastY, lastT; + let velX = 0, velY = 0; + + const onDown = (e) => { + if (e.button !== 0 && e.pointerType !== 'touch') return; + startX = lastX = e.clientX; startY = lastY = e.clientY; + lastT = Date.now(); velX = velY = 0; panning = false; + el.setPointerCapture(e.pointerId); + }; + + const onMove = (e) => { + const dx = e.clientX - startX, dy = e.clientY - startY; + const now = Date.now(), dt = now - lastT; + if (dt > 0) { + velX = (e.clientX - lastX) / dt * 1000; + velY = (e.clientY - lastY) / dt * 1000; + } + lastX = e.clientX; lastY = e.clientY; lastT = now; + + if (!panning && Math.sqrt(dx * dx + dy * dy) >= PAN_THRESHOLD) { + panning = true; + dotnetRef.invokeMethodAsync('OnPanStart_'); + } + if (panning) { + dotnetRef.invokeMethodAsync('OnPanMove', + e.clientX, e.clientY, + e.clientX - lastX, e.clientY - lastY, + e.clientX - startX, e.clientY - startY, + velX, velY); + } + }; + + const onUp = () => { if (panning) { panning = false; dotnetRef.invokeMethodAsync('OnPanEnd_'); } }; + + el.addEventListener('pointerdown', onDown); + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + cleanups.push(() => { + el.removeEventListener('pointerdown', onDown); + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + }); +} + +function _attachDrag(elementId, el, opts, dotnetRef, cleanups) { + const axis = opts.dragAxis ?? null; + const constraints = opts.dragConstraints ?? null; + const elastic = typeof opts.dragElastic === 'number' ? opts.dragElastic : 0.35; + const dirLock = !!opts.dragDirectionLock; + + let dragging = false; + let lockedAxis = null; // null = not yet locked, 'x' or 'y' once detected + let startPX, startPY, startElX, startElY; + let lastPX, lastPY, lastT, velX = 0, velY = 0; + + function applyElastic(overflow) { + return elastic > 0 ? overflow * elastic : 0; + } + + const onDown = (e) => { + if (e.button !== 0 && e.pointerType !== 'touch') return; + // Retrieve starting transform position from C# state synchronously + const pos = dotnetRef.invokeMethod('GetCurrentXY'); + startElX = pos ? pos.x : 0; + startElY = pos ? pos.y : 0; + startPX = e.clientX; startPY = e.clientY; + lastPX = e.clientX; lastPY = e.clientY; lastT = Date.now(); + velX = velY = 0; + dragging = true; + lockedAxis = null; + el.setPointerCapture(e.pointerId); + dotnetRef.invokeMethodAsync('OnPointerDown_Drag'); + }; + + const onMove = (e) => { + if (!dragging) return; + const now = Date.now(), dt = now - lastT; + if (dt > 0) { velX = (e.clientX - lastPX) / dt * 16; velY = (e.clientY - lastPY) / dt * 16; } + lastPX = e.clientX; lastPY = e.clientY; lastT = now; + + // Direction lock detection + let effectiveAxis = axis; + if (dirLock && !lockedAxis) { + const dx = Math.abs(e.clientX - startPX), dy = Math.abs(e.clientY - startPY); + if (dx > 3 || dy > 3) lockedAxis = dx >= dy ? 'x' : 'y'; + } + if (dirLock && lockedAxis) effectiveAxis = lockedAxis; + + let x = startElX + (effectiveAxis === 'y' ? 0 : e.clientX - startPX); + let y = startElY + (effectiveAxis === 'x' ? 0 : e.clientY - startPY); + + if (constraints) { + if (constraints.left != null && x < constraints.left) x = constraints.left - applyElastic(constraints.left - x); + if (constraints.right != null && x > constraints.right) x = constraints.right + applyElastic(x - constraints.right); + if (constraints.top != null && y < constraints.top) y = constraints.top - applyElastic(constraints.top - y); + if (constraints.bottom != null && y > constraints.bottom) y = constraints.bottom + applyElastic(y - constraints.bottom); + } + + // Sync drag position into C# state synchronously so ComputeFrame picks it up + dotnetRef.invokeMethod('SetDragPosition', x, y); + dotnetRef.invokeMethodAsync('OnDragMove'); + }; + + const onUp = (e) => { + if (!dragging) return; + dragging = false; + dotnetRef.invokeMethodAsync('OnPointerUp_Drag', velX, velY); + }; + + el.style.cursor = 'grab'; + el.style.userSelect = 'none'; + el.style.touchAction = axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none'; + + el.addEventListener('pointerdown', onDown); + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + cleanups.push(() => { + el.removeEventListener('pointerdown', onDown); + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + el.style.cursor = el.style.userSelect = el.style.touchAction = ''; + }); +} + +// +// Viewport observation (whileInView) +// + +// Cache observers keyed by their options signature so we can re-use them. +const _vpObservers = new Map(); // sig → IntersectionObserver +const _vpRefs = new Map(); // elementId → { dotnetRef, once } + +function _vpSig(margin, threshold) { return `${margin}|${threshold}`; } + +function _getVpObserver(margin, threshold) { + const sig = _vpSig(margin, threshold); + if (_vpObservers.has(sig)) return _vpObservers.get(sig); + const obs = new IntersectionObserver((entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute('data-bmid'); + const ref = _vpRefs.get(id); + if (!ref) continue; + ref.dotnetRef.invokeMethodAsync('OnIntersect', entry.isIntersecting); + if (ref.once && entry.isIntersecting) { + obs.unobserve(entry.target); + _vpRefs.delete(id); + } + } + }, { rootMargin: margin || '0px', threshold: threshold ?? 0 }); + _vpObservers.set(sig, obs); + return obs; +} + +export function observeViewport(elementId, dotnetRef, options) { + const el = document.getElementById(elementId); + if (!el) return; + const once = options?.once ?? false; + const margin = options?.margin ?? '0px'; + const threshold = options?.threshold ?? 0; + _vpRefs.set(elementId, { dotnetRef, once }); + _getVpObserver(margin, threshold).observe(el); +} + +export function unobserveViewport(elementId) { + const el = document.getElementById(elementId); + const ref = _vpRefs.get(elementId); + if (el && ref) { + // unobserve from every observer that might track this element + _vpObservers.forEach(obs => obs.unobserve(el)); + } + _vpRefs.delete(elementId); +} + +// +// FLIP layout animation support +// + +/** Returns the element's DOMRect as a plain object for C# to snapshot. */ +export function getBoundingRect(elementId) { + const el = document.getElementById(elementId); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left }; +} + +/** + * Run a FLIP animation via the Web Animations API. + * The element is currently at its NEW layout position; this animates it + * from the OLD (inverted) position to identity. + */ +export function playWaapiFlip(elementId, dx, dy, sx, sy, durationMs, easingStr, finalTransform) { + const el = document.getElementById(elementId); + if (!el) return; + el.style.transformOrigin = '0 0'; + const anim = el.animate( + [ + { transform: `translate(${dx}px,${dy}px) scaleX(${sx}) scaleY(${sy})` }, + { transform: 'translate(0px,0px) scaleX(1) scaleY(1)' }, + ], + { duration: durationMs, easing: easingStr || 'ease', fill: 'forwards' } + ); + anim.onfinish = () => { + el.style.transform = finalTransform || ''; + el.style.transformOrigin = ''; + }; +} + +// +// Scroll tracking +// + +let _scrollKeySeq = 0; +const _scrollSubs = new Map(); // key cleanup fn + +export function observeScroll(containerId, dotnetRef) { + const el = containerId ? document.getElementById(containerId) : window; + if (!el) return null; + const key = `scroll_${++_scrollKeySeq}`; + + const onScroll = () => { + let sX, sY, sW, sH, cW, cH; + if (el === window) { + sX = window.scrollX; sY = window.scrollY; + sW = document.documentElement.scrollWidth; + sH = document.documentElement.scrollHeight; + cW = window.innerWidth; cH = window.innerHeight; + } else { + sX = el.scrollLeft; sY = el.scrollTop; + sW = el.scrollWidth; sH = el.scrollHeight; + cW = el.clientWidth; cH = el.clientHeight; + } + const pX = sW > cW ? sX / (sW - cW) : 0; + const pY = sH > cH ? sY / (sH - cH) : 0; + dotnetRef.invokeMethodAsync('OnScroll', { + scrollX: sX, scrollY: sY, + progressX: pX, progressY: pY, + scrollWidth: sW, scrollHeight: sH, + clientWidth: cW, clientHeight: cH, + }); + }; + + el.addEventListener('scroll', onScroll, { passive: true }); + _scrollSubs.set(key, () => el.removeEventListener('scroll', onScroll)); + onScroll(); // fire immediately with current position + return key; +} + +export function unobserveScroll(key) { + _scrollSubs.get(key)?.(); + _scrollSubs.delete(key); +} + +export function observeElementScroll(elementId, dotnetRef) { + const el = document.getElementById(elementId); + if (!el) return null; + const key = `elscroll_${++_scrollKeySeq}`; + const io = new IntersectionObserver((entries) => { + for (const entry of entries) { + dotnetRef.invokeMethodAsync('OnElementScroll', { + progress: entry.intersectionRatio, + isIntersecting: entry.isIntersecting, + }); + } + }, { threshold: Array.from({ length: 101 }, (_, i) => i / 100) }); + io.observe(el); + _scrollSubs.set(key, () => io.unobserve(el)); + return key; +} diff --git a/src/Bmotion/README.md b/src/Bmotion/README.md new file mode 100644 index 0000000000..793bb6c2f9 --- /dev/null +++ b/src/Bmotion/README.md @@ -0,0 +1,385 @@ +# bit Bmotion + +A Blazor-native animation library inspired by [Framer Motion](https://www.framer.com/motion/). Springs, gestures, layout animations, variants, and keyframes — **zero JavaScript dependencies**. All animation math runs in C# via WebAssembly. + +> Targets **.NET 8, 9, and 10** + +--- + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Components](#components) + - [Motion](#motion) + - [AnimatePresence](#animatepresence) + - [MotionConfig](#motionconfig) +- [Animation Models](#animation-models) + - [AnimationProps](#animationprops) + - [TransitionConfig](#transitionconfig) + - [MotionVariants](#motionvariants) + - [DragOptions](#dragoptions) + - [ViewportOptions](#viewportoptions) +- [Services](#services) + - [AnimationController](#animationcontroller) + - [MotionAnimateService](#motionanimateservice) + - [MotionValue](#motionvalue) +- [Examples](#examples) +- [Accessibility](#accessibility) + +--- + +## Installation + +```bash +dotnet add package Bit.Bmotion +``` + +Register the services in `Program.cs`: + +```csharp +using Bit.Bmotion; + +builder.Services.AddBitBmotionServices(); +``` + +The browser bridge (`BitBmotion.js`) ships as a static web asset of the package and is +imported automatically the first time an animation runs, so no manual ` + diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs index 0835aeb328..3f890e59dd 100644 --- a/src/Bmotion/Bit.Bmotion/BitBmotion.cs +++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs @@ -1,6 +1,3 @@ -using Bit.Bmotion.Engine; -using Bit.Bmotion.Interop; -using Bit.Bmotion.Services; using Microsoft.Extensions.DependencyInjection; namespace Bit.Bmotion; @@ -20,20 +17,20 @@ public static IServiceCollection AddBitBmotionServices(this IServiceCollection s ArgumentNullException.ThrowIfNull(services); // Slim browser-API interop bridge - one instance per DI scope - services.AddScoped(); + services.AddScoped(); // C# animation engine - drives all animation math in WebAssembly - services.AddScoped(); + services.AddScoped(); // Higher-level services - // ScrollTracker is owned and disposed by the consuming component (like + // BmotionScrollTracker is owned and disposed by the consuming component (like // Framer Motion's per-component useScroll), so it must be transient. // A scoped (app-lifetime in WASM) instance would be disposed by the first // component to unmount, leaving its DotNetObjectReference disposed and // causing ObjectDisposedException when another component re-observes. - services.AddTransient(); - services.AddTransient(); - services.AddScoped(); + services.AddTransient(); + services.AddTransient(); + services.AddScoped(); return services; } diff --git a/src/Bmotion/Bit.Bmotion/Components/Motion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs similarity index 86% rename from src/Bmotion/Bit.Bmotion/Components/Motion.cs rename to src/Bmotion/Bit.Bmotion/Components/Bmotion.cs index 5aa9fcd6ca..7b71944446 100644 --- a/src/Bmotion/Bit.Bmotion/Components/Motion.cs +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -1,28 +1,23 @@ -using Bit.Bmotion.Context; -using Bit.Bmotion.Engine; -using Bit.Bmotion.Interop; -using Bit.Bmotion.Models; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.JSInterop; -namespace Bit.Bmotion.Components; - +namespace Bit.Bmotion; /// /// The primary animation component - a drop-in replacement for any HTML element. -/// Animation math runs in the C# ; JS is used only +/// Animation math runs in the C# ; JS is used only /// for DOM style mutation, pointer/focus events, viewport observation and FLIP. /// -public class Motion : ComponentBase, IAsyncDisposable +public class Bmotion : ComponentBase, IAsyncDisposable { // ── Injected services ────────────────────────────────────────────────────── - [Inject] private AnimationEngine Engine { get; set; } = null!; - [Inject] private MotionInterop Interop { get; set; } = null!; + [Inject] private BmotionAnimationEngine Engine { get; set; } = null!; + [Inject] private BmotionInterop Interop { get; set; } = null!; // ── Cascaded contexts ────────────────────────────────────────────────────── - [CascadingParameter] private PresenceContext? PresenceCtx { get; set; } - [CascadingParameter] private VariantContext? VariantCtx { get; set; } - [CascadingParameter] private MotionConfigContext? ConfigCtx { get; set; } + [CascadingParameter] private BmotionPresenceContext? PresenceCtx { get; set; } + [CascadingParameter] private BmotionVariantContext? VariantCtx { get; set; } + [CascadingParameter] private BmotionConfigContext? ConfigCtx { get; set; } // ── Core rendering parameters ──────────────────────────────────────────── [Parameter] public string Tag { get; set; } = "div"; @@ -33,20 +28,20 @@ public class Motion : ComponentBase, IAsyncDisposable public Dictionary? AdditionalAttributes { get; set; } // ── Animation targets ────────────────────────────────────────────────────── - [Parameter] public AnimationTarget? Initial { get; set; } - [Parameter] public AnimationTarget? Animate { get; set; } - [Parameter] public AnimationTarget? Exit { get; set; } + [Parameter] public BmotionAnimationTarget? Initial { get; set; } + [Parameter] public BmotionAnimationTarget? Animate { get; set; } + [Parameter] public BmotionAnimationTarget? Exit { get; set; } // ── Gesture states ───────────────────────────────────────────────────────── - [Parameter] public AnimationTarget? WhileHover { get; set; } - [Parameter] public AnimationTarget? WhileTap { get; set; } - [Parameter] public AnimationTarget? WhileFocus { get; set; } - [Parameter] public AnimationTarget? WhileDrag { get; set; } - [Parameter] public AnimationTarget? WhileInView { get; set; } + [Parameter] public BmotionAnimationTarget? WhileHover { get; set; } + [Parameter] public BmotionAnimationTarget? WhileTap { get; set; } + [Parameter] public BmotionAnimationTarget? WhileFocus { get; set; } + [Parameter] public BmotionAnimationTarget? WhileDrag { get; set; } + [Parameter] public BmotionAnimationTarget? WhileInView { get; set; } /// /// If true, fires only once and never deactivates. - /// Shorthand for Viewport = new ViewportOptions { Once = true }. + /// Shorthand for Viewport = new BmotionViewportOptions { Once = true }. /// [Parameter] public bool Once { get; set; } @@ -54,17 +49,17 @@ public class Motion : ComponentBase, IAsyncDisposable /// Advanced viewport options for (margin, amount, once). /// When set, is ignored in favour of Viewport.Once. /// - [Parameter] public ViewportOptions? Viewport { get; set; } + [Parameter] public BmotionViewportOptions? Viewport { get; set; } // ── Transition ───────────────────────────────────────────────────────────── - [Parameter] public TransitionConfig? Transition { get; set; } + [Parameter] public BmotionTransitionConfig? Transition { get; set; } // ── Variants ───────────────────────────────────────────────────────────── - [Parameter] public MotionVariants? Variants { get; set; } + [Parameter] public BmotionMotionVariants? Variants { get; set; } // ── Drag ───────────────────────────────────────────────────────────────── [Parameter] public bool Drag { get; set; } - [Parameter] public DragOptions? DragOptions { get; set; } + [Parameter] public BmotionDragOptions? DragOptions { get; set; } // ── Layout ───────────────────────────────────────────────────────────────── [Parameter] public bool Layout { get; set; } @@ -78,7 +73,7 @@ public class Motion : ComponentBase, IAsyncDisposable [Parameter] public EventCallback OnFocusStart { get; set; } [Parameter] public EventCallback OnFocusEnd { get; set; } [Parameter] public EventCallback OnPanStart { get; set; } - [Parameter] public EventCallback OnPan { get; set; } + [Parameter] public EventCallback OnPan { get; set; } [Parameter] public EventCallback OnPanEnd { get; set; } [Parameter] public EventCallback OnDragStart { get; set; } [Parameter] public EventCallback OnDrag { get; set; } @@ -91,14 +86,14 @@ public class Motion : ComponentBase, IAsyncDisposable // ── Internal state ───────────────────────────────────────────────────────── private readonly string _id = $"bm-{Guid.NewGuid():N}"; private ElementReference _ref; - private DotNetObjectReference? _dotnet; + private DotNetObjectReference? _dotnet; private bool _initialized; private bool _isExiting; - private AnimationTarget? _prevAnimate; - private VariantContext? _ownVariantCtx; + private BmotionAnimationTarget? _prevAnimate; + private BmotionVariantContext? _ownVariantCtx; private string? _prevInheritedVariant; private int _variantChildIndex = -1; - private BoundingRect? _layoutSnapshot; + private BmotionBoundingRect? _layoutSnapshot; // ════════════════════════════════════════════════════════════════════════════ // Rendering @@ -132,14 +127,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) if (Variants != null) { - _ownVariantCtx ??= new VariantContext(); + _ownVariantCtx ??= new BmotionVariantContext(); _ownVariantCtx.ActiveVariant = Animate?.IsVariant == true ? Animate.Variant : null; _ownVariantCtx.InitialVariant = Initial?.IsVariant == true ? Initial.Variant : null; _ownVariantCtx.Variants = Variants; _ownVariantCtx.StaggerChildren = Transition?.StaggerChildren ?? 0; _ownVariantCtx.DelayChildren = Transition?.DelayChildren ?? 0; - builder.OpenComponent>(7); + builder.OpenComponent>(7); builder.AddComponentParameter(8, "Value", _ownVariantCtx); builder.AddComponentParameter(9, "ChildContent", ChildContent); builder.CloseComponent(); @@ -210,7 +205,7 @@ protected override async Task OnParametersSetAsync() private async Task InitialiseAsync() { // Reduced-motion is opt-in: only probe the OS preference when this element is - // inside a . Elements without a config always animate normally. + // inside a . Elements without a config always animate normally. if (ConfigCtx is not null) await Engine.EnsureReducedMotionDetectedAsync(); @@ -265,7 +260,7 @@ private async Task HandleParameterUpdateAsync() { if (_isExiting) return; - if (!AnimationTarget.AreEquivalent(_prevAnimate, Animate)) + if (!BmotionAnimationTarget.AreEquivalent(_prevAnimate, Animate)) { var animateProps = ResolveProps(Animate); if (animateProps != null) @@ -308,7 +303,7 @@ internal async Task PlayExitAsync() PresenceCtx?.NotifyExitComplete(this); } - private async Task PlayFlipAsync(BoundingRect snap) + private async Task PlayFlipAsync(BmotionBoundingRect snap) { var cur = await Interop.GetBoundingRectAsync(_id); if (cur == null) return; @@ -322,10 +317,10 @@ private async Task PlayFlipAsync(BoundingRect snap) return; var t = BuildEffectiveTransition(); - double dur = t?.Type == TransitionType.Spring ? 600 : (t?.Duration ?? 0.5) * 1000; - string easing = t?.Type == TransitionType.Spring + double dur = t?.Type == BmotionTransitionType.Spring ? 600 : (t?.Duration ?? 0.5) * 1000; + string easing = t?.Type == BmotionTransitionType.Spring ? "cubic-bezier(0.14,1,0.34,1)" - : EasingFunctions.ToCssString(t); + : BmotionEasingFunctions.ToCssString(t); string? finalT = Engine.GetCurrentTransformString(_id); await Interop.PlayWaapiFlipAsync(_id, dx, dy, sx, sy, dur, easing, finalT); @@ -335,15 +330,15 @@ private async Task PlayFlipAsync(BoundingRect snap) // Programmatic API // ════════════════════════════════════════════════════════════════════════════ - public async ValueTask AnimateAsync(AnimationProps props, TransitionConfig? transition = null) + public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { transition ??= BuildEffectiveTransition(); await Engine.AnimateToAsync(_id, props.ToJsDictionary(), transition); } - public void Set(AnimationProps props) => Engine.SetInstant(_id, props.ToJsDictionary()); + public void Set(BmotionAnimationProps props) => Engine.SetInstant(_id, props.ToJsDictionary()); - public async ValueTask SetAsync(AnimationProps props) + public async ValueTask SetAsync(BmotionAnimationProps props) { Engine.SetInstant(_id, props.ToJsDictionary()); // Flush synchronous style update to DOM as individual declarations (never via cssText, @@ -450,12 +445,12 @@ public async Task OnPointerUp_Drag(double velX, double velY) if (WhileDrag != null) await Engine.DeactivateGestureLayerAsync(_id, "drag"); - var dragOpt = DragOptions ?? new DragOptions(); + var dragOpt = DragOptions ?? new BmotionDragOptions(); if (dragOpt.SnapToOrigin) { - var snapT = dragOpt.SnapTransition ?? new TransitionConfig - { Type = TransitionType.Spring, Stiffness = 400, Damping = 35 }; + var snapT = dragOpt.SnapTransition ?? new BmotionTransitionConfig + { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 }; await Engine.AnimateToAsync(_id, new Dictionary { ["x"] = 0.0, ["y"] = 0.0 }, snapT); } @@ -463,7 +458,7 @@ await Engine.AnimateToAsync(_id, { await Engine.EndDragAsync( _id, velX, velY, dragOpt.Momentum, dragOpt.Constraints, - dragOpt.Axis == DragAxis.Both ? null : dragOpt.Axis.ToString().ToLowerInvariant(), + dragOpt.Axis == BmotionDragAxis.Both ? null : dragOpt.Axis.ToString().ToLowerInvariant(), dragOpt.SnapTransition); } @@ -481,12 +476,12 @@ public async Task OnPanMove(double pointX, double pointY, { if (OnPan.HasDelegate) { - await OnPan.InvokeAsync(new PanInfo + await OnPan.InvokeAsync(new BmotionPanInfo { - Point = new PointInfo { X = pointX, Y = pointY }, - Delta = new PointInfo { X = deltaX, Y = deltaY }, - Offset = new PointInfo { X = offsetX, Y = offsetY }, - Velocity = new PointInfo { X = velocityX, Y = velocityY }, + Point = new BmotionPointInfo { X = pointX, Y = pointY }, + Delta = new BmotionPointInfo { X = deltaX, Y = deltaY }, + Offset = new BmotionPointInfo { X = offsetX, Y = offsetY }, + Velocity = new BmotionPointInfo { X = velocityX, Y = velocityY }, }); } } @@ -529,10 +524,10 @@ private bool NeedsPathLengthAttr() => HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) || HasPathLength(WhileInView) || HasPathLength(WhileDrag)); - private static bool HasPathLength(AnimationTarget? t) => + private static bool HasPathLength(BmotionAnimationTarget? t) => t?.Props?.PathLength != null; - private AnimationProps? ResolveProps(AnimationTarget? target) + private BmotionAnimationProps? ResolveProps(BmotionAnimationTarget? target) { if (target == null || target.IsDisabled) return null; if (target.HasProps) return target.Props; @@ -548,8 +543,8 @@ private static bool HasPathLength(AnimationTarget? t) => /// Resolves whether motion should be reduced for this element. /// /// Reduced motion is opt-in: an element only reduces motion when it is inside a - /// . Within one, an explicit - /// value (true/false) always wins; when it is + /// . Within one, an explicit + /// value (true/false) always wins; when it is /// null the OS prefers-reduced-motion preference is respected. Elements with no /// surrounding config always animate, so the OS preference never silently disables animations /// an app didn't opt into. @@ -562,10 +557,10 @@ private bool ShouldReduceMotion() } /// An instant (zero-duration) transition used when motion is reduced. - private static TransitionConfig InstantTransition() - => new() { Type = TransitionType.Tween, Duration = 0, Delay = 0 }; + private static BmotionTransitionConfig InstantTransition() + => new() { Type = BmotionTransitionType.Tween, Duration = 0, Delay = 0 }; - private TransitionConfig? BuildEffectiveTransition() + private BmotionTransitionConfig? BuildEffectiveTransition() { // Reduced motion: collapse every animation to an instant state change. if (ShouldReduceMotion()) return InstantTransition(); @@ -580,12 +575,12 @@ private static TransitionConfig InstantTransition() return t; } - private TransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay) + private BmotionTransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay) { // Reduced motion stays instant - stagger delays are skipped too. if (ShouldReduceMotion()) return InstantTransition(); - var t = BuildEffectiveTransition() ?? new TransitionConfig(); + var t = BuildEffectiveTransition() ?? new BmotionTransitionConfig(); if (extraDelay <= 0) return t; t = t.Clone(); t.Delay += extraDelay; @@ -602,8 +597,8 @@ private TransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay) if (Drag) { d["drag"] = true; - var dragOpt = DragOptions ?? new DragOptions(); - if (dragOpt.Axis != DragAxis.Both) d["dragAxis"] = dragOpt.Axis.ToString().ToLowerInvariant(); + var dragOpt = DragOptions ?? new BmotionDragOptions(); + if (dragOpt.Axis != BmotionDragAxis.Both) d["dragAxis"] = dragOpt.Axis.ToString().ToLowerInvariant(); d["dragElastic"] = dragOpt.Elastic; if (dragOpt.Constraints != null) d["dragConstraints"] = dragOpt.Constraints.ToJsObject(); if (dragOpt.DirectionLock) d["dragDirectionLock"] = true; diff --git a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor similarity index 64% rename from src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor rename to src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor index d8aa9b8776..70f5e83258 100644 --- a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor @@ -1,6 +1,6 @@ -@using Bit.Bmotion.Context +@namespace Bit.Bmotion -@* AnimatePresence keeps its children alive while they play exit animations, +@* BmotionAnimatePresence keeps its children alive while they play exit animations, then stops rendering them once every child reports completion. *@ @if (_shouldRender) diff --git a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs similarity index 88% rename from src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs rename to src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs index 67a1a6df34..686e944ea5 100644 --- a/src/Bmotion/Bit.Bmotion/Components/AnimatePresence.razor.cs +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs @@ -1,8 +1,6 @@ -using Bit.Bmotion.Context; using Microsoft.AspNetCore.Components; -namespace Bit.Bmotion.Components; - +namespace Bit.Bmotion; /// /// Wraps content that should animate in and out. /// When switches from true to false, children @@ -12,18 +10,18 @@ namespace Bit.Bmotion.Components; /// Limitation: presence is controlled by a single flag for the /// whole subtree (all-or-nothing). This does not provide per-item enter/exit tracking for keyed /// lists the way Framer Motion's keyed AnimatePresence does; wrap individual items in their -/// own if you need independent exit animations per item. +/// own if you need independent exit animations per item. /// /// /// /// -/// <AnimatePresence IsPresent="@_visible"> -/// <Motion Tag="div" Animate="..." Exit="..." /> -/// </AnimatePresence> +/// <BmotionAnimatePresence IsPresent="@_visible"> +/// <Bmotion Tag="div" Animate="..." Exit="..." /> +/// </BmotionAnimatePresence> /// /// /// -public partial class AnimatePresence : ComponentBase +public partial class BmotionAnimatePresence : ComponentBase { // ── Parameters ──────────────────────────────────────────────────────────── @@ -43,7 +41,7 @@ public partial class AnimatePresence : ComponentBase // ── Internal state ──────────────────────────────────────────────────────── - private readonly PresenceContext _presenceCtx = new(); + private readonly BmotionPresenceContext _presenceCtx = new(); private bool _shouldRender = true; private bool _prevIsPresent = true; private bool _deferEnter; @@ -61,7 +59,7 @@ protected override void OnParametersSet() { if (_prevIsPresent && !IsPresent) { - // Children are leaving - signal exiting state so Motion components play Exit + // Children are leaving - signal exiting state so Bmotion components play Exit _presenceCtx.IsExiting = true; _shouldRender = true; // keep rendering until exit completes } diff --git a/src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor similarity index 79% rename from src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor rename to src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor index ba46889ff4..cb84e677fc 100644 --- a/src/Bmotion/Bit.Bmotion/Components/MotionConfig.razor +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor @@ -1,7 +1,6 @@ -@using Bit.Bmotion.Context -@using Bit.Bmotion.Models +@namespace Bit.Bmotion -@* MotionConfig provides global animation defaults to the entire subtree. *@ +@* BmotionConfig provides global animation defaults to the entire subtree. *@ @ChildContent @@ -11,7 +10,7 @@ [Parameter] public RenderFragment? ChildContent { get; set; } /// Global default transition applied when no per-component transition is set. - [Parameter] public TransitionConfig? Transition { get; set; } + [Parameter] public BmotionTransitionConfig? Transition { get; set; } /// /// Global reduce-motion override. @@ -27,7 +26,7 @@ /// [Parameter] public double TransitionSpeed { get; set; } = 1.0; - private readonly MotionConfigContext _ctx = new(); + private readonly BmotionConfigContext _ctx = new(); protected override void OnParametersSet() { diff --git a/src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs similarity index 73% rename from src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs rename to src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs index 190cbf1121..2d317bcc7a 100644 --- a/src/Bmotion/Bit.Bmotion/Context/MotionConfigContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs @@ -1,14 +1,12 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Context; +namespace Bit.Bmotion; /// -/// Cascaded by to set library-wide defaults. +/// Cascaded by to set library-wide defaults. /// -public class MotionConfigContext +public class BmotionConfigContext { /// Global default transition applied when no individual transition is set. - public TransitionConfig? DefaultTransition { get; set; } + public BmotionTransitionConfig? DefaultTransition { get; set; } /// /// When true, all animations are skipped (useful for accessibility / reduced-motion). diff --git a/src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs similarity index 70% rename from src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs rename to src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs index 2e0604862f..f3b87a3418 100644 --- a/src/Bmotion/Bit.Bmotion/Context/PresenceContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs @@ -1,28 +1,26 @@ -using Bit.Bmotion.Components; - -namespace Bit.Bmotion.Context; +namespace Bit.Bmotion; /// -/// Cascaded by to signal exit state to child Motion components. +/// Cascaded by to signal exit state to child Bmotion components. /// -public class PresenceContext +public class BmotionPresenceContext { - private readonly List _children = new(); + private readonly List _children = new(); /// True while the children are playing their exit animation. public bool IsExiting { get; internal set; } - internal void Register(Motion child) + internal void Register(Bmotion child) { if (!_children.Contains(child)) _children.Add(child); } - internal void Unregister(Motion child) => _children.Remove(child); + internal void Unregister(Bmotion child) => _children.Remove(child); internal int ChildCount => _children.Count; - private readonly HashSet _completedChildren = new(); + private readonly HashSet _completedChildren = new(); - internal void NotifyExitComplete(Motion child) + internal void NotifyExitComplete(Bmotion child) { // Ignore unregistered children and guard against double-counting. if (!_children.Contains(child)) return; diff --git a/src/Bmotion/Bit.Bmotion/Context/VariantContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs similarity index 77% rename from src/Bmotion/Bit.Bmotion/Context/VariantContext.cs rename to src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs index 94ea54d0fd..4097124b25 100644 --- a/src/Bmotion/Bit.Bmotion/Context/VariantContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs @@ -1,12 +1,10 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Context; +namespace Bit.Bmotion; /// -/// Cascaded by a parent Motion component to propagate the active variant name, -/// shared variants dictionary, and stagger configuration to descendant Motion components. +/// Cascaded by a parent Bmotion component to propagate the active variant name, +/// shared variants dictionary, and stagger configuration to descendant Bmotion components. /// -public class VariantContext +public class BmotionVariantContext { private int _nextChildIndex; @@ -17,7 +15,7 @@ public class VariantContext public string? InitialVariant { get; internal set; } /// Shared variants dictionary from the nearest ancestor that defined variants. - public MotionVariants? Variants { get; internal set; } + public BmotionMotionVariants? Variants { get; internal set; } /// Seconds to stagger each child's animation start. public double StaggerChildren { get; internal set; } @@ -26,7 +24,7 @@ public class VariantContext public double DelayChildren { get; internal set; } /// - /// Called by a child Motion component once on first render to obtain a stable + /// Called by a child Bmotion component once on first render to obtain a stable /// position in the stagger sequence. Returns the child's index. /// internal int RegisterChild() => _nextChildIndex++; diff --git a/src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs similarity index 91% rename from src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index b6373d594a..7a7feeaf69 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/AnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -1,9 +1,6 @@ -using Bit.Bmotion.Interop; -using Bit.Bmotion.Models; using Microsoft.JSInterop; -namespace Bit.Bmotion.Engine; - +namespace Bit.Bmotion; /// /// Central C# animation engine - the JS equivalent of the full BitBmotion.js /// animation loop, now running in Blazor WebAssembly. @@ -13,15 +10,15 @@ namespace Bit.Bmotion.Engine; /// requestAnimationFrame tick and receives back a dictionary of /// CSS style updates to apply to the DOM. /// -public sealed class AnimationEngine : IAsyncDisposable +public sealed class BmotionAnimationEngine : IAsyncDisposable { - private readonly MotionInterop _interop; - private readonly Dictionary _elements = new(); - private DotNetObjectReference? _dotnet; + private readonly BmotionInterop _interop; + private readonly Dictionary _elements = new(); + private DotNetObjectReference? _dotnet; private bool _loopRunning; private bool _reducedMotionDetected; - public AnimationEngine(MotionInterop interop) => _interop = interop; + public BmotionAnimationEngine(BmotionInterop interop) => _interop = interop; // ═══════════════════════════════════════════════════════════════════════════ // Reduced-motion (accessibility) @@ -62,7 +59,7 @@ public void RegisterElement(string elementId, Dictionary? initi { if (!_elements.TryGetValue(elementId, out var state)) { - state = new ElementAnimationState(); + state = new BmotionElementAnimationState(); _elements[elementId] = state; } if (initialValues != null) @@ -87,7 +84,7 @@ public void UnregisterElement(string elementId) public async ValueTask AnimateToAsync( string elementId, Dictionary values, - TransitionConfig? transition, + BmotionTransitionConfig? transition, Func? onComplete = null) { if (!_elements.TryGetValue(elementId, out var state)) return; @@ -112,7 +109,7 @@ public async ValueTask AnimateToAsync( public async ValueTask AnimateToAwaitAsync( string elementId, Dictionary values, - TransitionConfig? transition) + BmotionTransitionConfig? transition) { if (!_elements.TryGetValue(elementId, out var state)) return; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -164,7 +161,7 @@ public void Stop(string elementId, string[]? properties) public async ValueTask ActivateGestureLayerAsync( string elementId, string gesture, - Dictionary values, TransitionConfig? transition) + Dictionary values, BmotionTransitionConfig? transition) { if (!_elements.TryGetValue(elementId, out var state)) return; state.ActivateGestureLayer(gesture, values, transition); @@ -208,9 +205,9 @@ public async ValueTask EndDragAsync( string elementId, double velX, double velY, bool momentum, - DragConstraints? constraints, + BmotionDragConstraints? constraints, string? axis, - TransitionConfig? snapTransition) + BmotionTransitionConfig? snapTransition) { if (!_elements.TryGetValue(elementId, out var state)) return; @@ -222,9 +219,9 @@ public async ValueTask EndDragAsync( { if (axis != "y" && Math.Abs(velX) > 0.5) { - var inertiaX = new TransitionConfig + var inertiaX = new BmotionTransitionConfig { - Type = TransitionType.Inertia, + Type = BmotionTransitionType.Inertia, InertiaVelocity = velX * 50, InertiaMin = constraints?.Left, InertiaMax = constraints?.Right, @@ -235,9 +232,9 @@ public async ValueTask EndDragAsync( if (axis != "x" && Math.Abs(velY) > 0.5) { - var inertiaY = new TransitionConfig + var inertiaY = new BmotionTransitionConfig { - Type = TransitionType.Inertia, + Type = BmotionTransitionType.Inertia, InertiaVelocity = velY * 50, InertiaMin = constraints?.Top, InertiaMax = constraints?.Bottom, @@ -251,8 +248,8 @@ public async ValueTask EndDragAsync( // Snap to constraint bounds double cx = posX, cy = posY; bool snap = false; - var snapT = snapTransition ?? new TransitionConfig - { Type = TransitionType.Spring, Stiffness = 400, Damping = 35 }; + var snapT = snapTransition ?? new BmotionTransitionConfig + { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 }; if (axis != "y") { @@ -282,11 +279,11 @@ public async ValueTask EndDragAsync( public string? GetCurrentTransformString(string elementId) { if (!_elements.TryGetValue(elementId, out var state)) return null; - return TransformComposer.Build(state.Transforms); + return BmotionTransformComposer.Build(state.Transforms); } - /// Returns the for an element, or null. - internal ElementAnimationState? GetState(string elementId) + /// Returns the for an element, or null. + internal BmotionElementAnimationState? GetState(string elementId) => _elements.GetValueOrDefault(elementId); // ═══════════════════════════════════════════════════════════════════════════ @@ -357,7 +354,7 @@ public async ValueTask DisposeAsync() _elements.Clear(); StopLoopInternal(); _dotnet?.Dispose(); - // MotionInterop is owned and disposed by the DI container (it is registered scoped), + // BmotionInterop is owned and disposed by the DI container (it is registered scoped), // so the engine must not dispose it here or it would be disposed twice. await ValueTask.CompletedTask; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs similarity index 83% rename from src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs index 919f85e6ce..fa8c758591 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/ColorInterpolator.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs @@ -1,10 +1,9 @@ -namespace Bit.Bmotion.Engine; - +namespace Bit.Bmotion; /// /// Pure-C# RGBA color parsing and linear interpolation. /// Handles #hex, rgb(), rgba(), hsl(), and hsla() formats. /// -internal static class ColorInterpolator +internal static class BmotionColorInterpolator { /// Linearly interpolates between two CSS color strings at progress (0–1). public static string Lerp(string from, string to, double t) @@ -17,7 +16,7 @@ public static string Lerp(string from, string to, double t) int g = (int)Math.Round(f[1] + (tt[1] - f[1]) * t); int b = (int)Math.Round(f[2] + (tt[2] - f[2]) * t); double a = f[3] + (tt[3] - f[3]) * t; - return $"rgba({r},{g},{b},{CssFormat.Num(a, "G4")})"; + return $"rgba({r},{g},{b},{BmotionCssFormat.Num(a, "G4")})"; } /// Returns true if the CSS string looks like a color value. @@ -60,10 +59,10 @@ public static bool LooksLikeColor(string? value) { return [ - CssFormat.Parse(m.Groups[1].Value), - CssFormat.Parse(m.Groups[2].Value), - CssFormat.Parse(m.Groups[3].Value), - m.Groups[4].Success ? CssFormat.Parse(m.Groups[4].Value) : 1.0, + BmotionCssFormat.Parse(m.Groups[1].Value), + BmotionCssFormat.Parse(m.Groups[2].Value), + BmotionCssFormat.Parse(m.Groups[3].Value), + m.Groups[4].Success ? BmotionCssFormat.Parse(m.Groups[4].Value) : 1.0, ]; } @@ -72,10 +71,10 @@ public static bool LooksLikeColor(string? value) c, @"hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)"); if (mh.Success) { - double h2 = CssFormat.Parse(mh.Groups[1].Value); - double s2 = CssFormat.Parse(mh.Groups[2].Value) / 100.0; - double l2 = CssFormat.Parse(mh.Groups[3].Value) / 100.0; - double a2 = mh.Groups[4].Success ? CssFormat.Parse(mh.Groups[4].Value) : 1.0; + double h2 = BmotionCssFormat.Parse(mh.Groups[1].Value); + double s2 = BmotionCssFormat.Parse(mh.Groups[2].Value) / 100.0; + double l2 = BmotionCssFormat.Parse(mh.Groups[3].Value) / 100.0; + double a2 = mh.Groups[4].Success ? BmotionCssFormat.Parse(mh.Groups[4].Value) : 1.0; var rgb2 = HslToRgb(h2, s2, l2); return [rgb2[0], rgb2[1], rgb2[2], a2]; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs new file mode 100644 index 0000000000..7617085317 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs @@ -0,0 +1,74 @@ +namespace Bit.Bmotion; +/// Keyframe animation driver for CSS color string properties. +internal sealed class BmotionColorKeyframesDriver : IBmotionAnimationDriver +{ + private readonly string[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly BmotionRepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private string[] _curFrames; + + public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig config, Action apply) + { + _frames = frames; + _curFrames = (string[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + var globalEase = BmotionEasingFunctions.Get(config); + _eases = Enumerable.Repeat(globalEase, n - 1).ToArray(); + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_frames[^1]); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; + + int n = _curFrames.Length; + int seg = n - 2; + for (int i = 0; i < n - 1; i++) { if (t <= _times[i + 1]) { seg = i; break; } } + double segLen = _times[seg + 1] - _times[seg]; + double segT = segLen > 0 ? (t - _times[seg]) / segLen : 1.0; + double easedT = _eases[seg](Math.Min(segT, 1.0)); + _apply(BmotionColorInterpolator.Lerp(_curFrames[seg], _curFrames[seg + 1], easedT)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) + Array.Reverse(_curFrames); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() => _apply(_frames[^1]); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs similarity index 78% rename from src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs index dc559ddd63..3ffe335257 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/ColorTweenDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs @@ -1,9 +1,7 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// Tween animation driver for CSS color string properties. -internal sealed class ColorTweenDriver : IAnimationDriver +internal sealed class BmotionColorTweenDriver : IBmotionAnimationDriver { private readonly string _to; private readonly double _durationMs; @@ -11,7 +9,7 @@ internal sealed class ColorTweenDriver : IAnimationDriver private readonly Func _easeFn; private readonly int _repeat; private readonly bool _isInfinite; - private readonly RepeatType _repeatType; + private readonly BmotionRepeatType _repeatType; private readonly double _repeatDelayMs; private readonly Action _apply; @@ -21,13 +19,13 @@ internal sealed class ColorTweenDriver : IAnimationDriver private string _curFrom; private string _curTo; - public ColorTweenDriver(string from, string to, TransitionConfig config, Action apply) + public BmotionColorTweenDriver(string from, string to, BmotionTransitionConfig config, Action apply) { _curFrom = from; _curTo = _to = to; _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; - _easeFn = EasingFunctions.Get(config); + _easeFn = BmotionEasingFunctions.Get(config); _repeat = config.Repeat; _isInfinite = config.Repeat == int.MaxValue; _repeatType = config.RepeatType; @@ -45,7 +43,7 @@ public bool Tick(double timestamp) double elapsed = timestamp - _startTime; double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0; double p = _easeFn(t); - _apply(ColorInterpolator.Lerp(_curFrom, _curTo, p)); + _apply(BmotionColorInterpolator.Lerp(_curFrom, _curTo, p)); if (t >= 1.0) { @@ -53,7 +51,7 @@ public bool Tick(double timestamp) { if (!_isInfinite) _iteration++; _startTime = timestamp + _repeatDelayMs; - if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) (_curFrom, _curTo) = (_curTo, _curFrom); return false; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/CssFormat.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs similarity index 96% rename from src/Bmotion/Bit.Bmotion/Engine/CssFormat.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs index deb01133e3..2a6b785b20 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/CssFormat.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs @@ -1,7 +1,6 @@ using System.Globalization; -namespace Bit.Bmotion.Engine; - +namespace Bit.Bmotion; /// /// Centralised, culture-invariant number↔CSS conversion helpers. /// @@ -13,7 +12,7 @@ namespace Bit.Bmotion.Engine; /// . /// /// -internal static class CssFormat +internal static class BmotionCssFormat { /// Formats a double as an invariant-culture string with full round-trip precision. public static string Num(double value) diff --git a/src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs similarity index 73% rename from src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs index 277ec71b3b..0c8f1b8ee3 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/EasingFunctions.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs @@ -1,12 +1,10 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// /// Pure-C# easing functions. Ported from the original JS implementation. /// Cached delegates avoid re-allocation for common easing types. /// -internal static class EasingFunctions +internal static class BmotionEasingFunctions { // ── Pre-built delegates for common easings ──────────────────────────────── private static readonly Func _easeIn = CubicBezier(0.42, 0, 1, 1); @@ -17,26 +15,26 @@ internal static class EasingFunctions private static readonly Func _backInOut = CubicBezier(0.68987, -0.45, 0.32, 1.45); /// Returns an easing function for the given transition config. - public static Func Get(TransitionConfig config) + public static Func Get(BmotionTransitionConfig config) { if (config.EaseCubicBezier is { Length: 4 } cb) return CubicBezier(cb[0], cb[1], cb[2], cb[3]); return config.Ease switch { - Easing.Linear => t => t, - Easing.EaseIn => _easeIn, - Easing.EaseOut => _easeOut, - Easing.EaseInOut => _easeInOut, - Easing.CircIn => t => 1 - Math.Sqrt(1 - t * t), - Easing.CircOut => t => Math.Sqrt(1 - (t - 1) * (t - 1)), - Easing.CircInOut => t => t < 0.5 + BmotionEasing.Linear => t => t, + BmotionEasing.EaseIn => _easeIn, + BmotionEasing.EaseOut => _easeOut, + BmotionEasing.EaseInOut => _easeInOut, + BmotionEasing.CircIn => t => 1 - Math.Sqrt(1 - t * t), + BmotionEasing.CircOut => t => Math.Sqrt(1 - (t - 1) * (t - 1)), + BmotionEasing.CircInOut => t => t < 0.5 ? (1 - Math.Sqrt(1 - 4 * t * t)) / 2 : (Math.Sqrt(1 - Math.Pow(2 * t - 2, 2)) + 1) / 2, - Easing.BackIn => _backIn, - Easing.BackOut => _backOut, - Easing.BackInOut => _backInOut, - Easing.Anticipate => t => t < 0.5 + BmotionEasing.BackIn => _backIn, + BmotionEasing.BackOut => _backOut, + BmotionEasing.BackInOut => _backInOut, + BmotionEasing.Anticipate => t => t < 0.5 ? _backIn(t * 2) / 2 : _easeOut(t * 2 - 1) / 2 + 0.5, _ => _easeOut, @@ -44,17 +42,17 @@ public static Func Get(TransitionConfig config) } /// Returns a CSS easing string for use with the Web Animations API (FLIP). - public static string ToCssString(TransitionConfig? config) + public static string ToCssString(BmotionTransitionConfig? config) { if (config == null) return "ease"; if (config.EaseCubicBezier is { Length: 4 } cb) return $"cubic-bezier({cb[0]},{cb[1]},{cb[2]},{cb[3]})"; return config.Ease switch { - Easing.Linear => "linear", - Easing.EaseIn => "ease-in", - Easing.EaseOut => "ease-out", - Easing.EaseInOut => "ease-in-out", + BmotionEasing.Linear => "linear", + BmotionEasing.EaseIn => "ease-in", + BmotionEasing.EaseOut => "ease-out", + BmotionEasing.EaseInOut => "ease-in-out", _ => "ease", }; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs similarity index 88% rename from src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs index 77183a06ac..46a2d0a735 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/ElementAnimationState.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -1,14 +1,12 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// /// Per-element animation state - the C# equivalent of the JS ElementState class. /// Holds current transform / numeric / color values, active animation drivers, -/// and gesture-layer bookkeeping. Called by +/// and gesture-layer bookkeeping. Called by /// every rAF tick. /// -internal sealed class ElementAnimationState +internal sealed class BmotionElementAnimationState { // ── Live CSS values ─────────────────────────────────────────────────────── @@ -22,13 +20,13 @@ internal sealed class ElementAnimationState internal readonly Dictionary StringValues = new(); // ── Active animations ───────────────────────────────────────────────────── - private readonly Dictionary _activeAnims = new(); + private readonly Dictionary _activeAnims = new(); // ── Gesture layer stack ──────────────────────────────────────────────────── private static readonly string[] GesturePriority = ["drag", "focus", "tap", "hover", "inview"]; private readonly Dictionary _gestureLayers = new(); private Dictionary? _baseValues; - private TransitionConfig? _baseTransition; + private BmotionTransitionConfig? _baseTransition; // ── Animation completion tracking ───────────────────────────────────────── private TaskCompletionSource? _completionSource; @@ -80,7 +78,7 @@ internal sealed class ElementAnimationState var updates = new Dictionary(_dirtyProps.Count + 1); if (_transformDirty) - updates["transform"] = TransformComposer.Build(Transforms); + updates["transform"] = BmotionTransformComposer.Build(Transforms); foreach (var prop in _dirtyProps) { @@ -90,26 +88,26 @@ internal sealed class ElementAnimationState double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0); double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); - updates["strokeDasharray"] = CssFormat.Num(len) + " " + CssFormat.Num(spacing); + updates["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing); // Offset combines the "draw from end" baseline (1 - len) with any explicit pathOffset. - updates["strokeDashoffset"] = CssFormat.Num(1 - len - offset); + updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); } else if (prop == "pathOffset") { double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); - updates["strokeDashoffset"] = CssFormat.Num(1 - len - offset); + updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); } else if (prop.StartsWith("--")) { if (NumericValues.TryGetValue(prop, out double nv)) - updates[prop] = CssFormat.Num(nv); + updates[prop] = BmotionCssFormat.Num(nv); else if (StringValues.TryGetValue(prop, out string? sv)) updates[prop] = sv; } else if (NumericValues.TryGetValue(prop, out double numVal)) { - updates[prop] = CssFormat.Num(numVal); + updates[prop] = BmotionCssFormat.Num(numVal); } else if (StringValues.TryGetValue(prop, out string? strVal)) { @@ -130,7 +128,7 @@ internal sealed class ElementAnimationState public void AnimateTo( Dictionary values, - TransitionConfig? transition, + BmotionTransitionConfig? transition, TaskCompletionSource? completionSource = null) { // Cheap scan for any non-null target (no allocation). @@ -148,7 +146,7 @@ public void AnimateTo( foreach (var (key, value) in values) { if (value == null) continue; - var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new TransitionConfig(); + var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new BmotionTransitionConfig(); CancelProp(key); if (TryGetDoubleArray(value, out double[]? doubleFrames)) @@ -169,7 +167,7 @@ public void SetInstant(Dictionary values) foreach (var (key, value) in values) { if (value == null) continue; - if (TransformComposer.IsTransformProp(key)) + if (BmotionTransformComposer.IsTransformProp(key)) { Transforms[key] = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); _transformDirty = true; @@ -237,13 +235,13 @@ internal void CancelProp(string key) // Gesture layer management // ═══════════════════════════════════════════════════════════════════════════ - public void SetBaseAnimation(Dictionary values, TransitionConfig? transition) + public void SetBaseAnimation(Dictionary values, BmotionTransitionConfig? transition) { _baseValues = values; _baseTransition = transition; } - public void ActivateGestureLayer(string gesture, Dictionary values, TransitionConfig? transition) + public void ActivateGestureLayer(string gesture, Dictionary values, BmotionTransitionConfig? transition) { _gestureLayers[gesture] = new GestureLayer(values, transition); AnimateTo(values, transition); @@ -257,7 +255,7 @@ public void DeactivateGestureLayer(string gesture) // Build the target the element should revert to: the base animation overlaid with // every still-active gesture layer (lowest priority first so higher priority wins). var target = new Dictionary(); - TransitionConfig? transition = _baseTransition; + BmotionTransitionConfig? transition = _baseTransition; if (_baseValues != null) foreach (var kv in _baseValues) @@ -278,7 +276,7 @@ public void DeactivateGestureLayer(string gesture) foreach (var key in removed.Values.Keys) { if (target.ContainsKey(key)) continue; - if (TransformComposer.IsTransformProp(key)) + if (BmotionTransformComposer.IsTransformProp(key)) target[key] = DefaultTransformValue(key); else if (!IsColorProp(key)) // colours have no safe identity to revert to target[key] = DefaultNumericValue(key); @@ -309,9 +307,9 @@ public void SetDragPosition(double x, double y) // Driver factory helpers // ═══════════════════════════════════════════════════════════════════════════ - private void CreateNumericDriver(string key, double toValue, TransitionConfig config) + private void CreateNumericDriver(string key, double toValue, BmotionTransitionConfig config) { - bool isTransform = TransformComposer.IsTransformProp(key); + bool isTransform = BmotionTransformComposer.IsTransformProp(key); double from = isTransform ? Transforms.GetValueOrDefault(key, DefaultTransformValue(key)) : NumericValues.GetValueOrDefault(key, DefaultNumericValue(key)); @@ -327,34 +325,34 @@ private void CreateNumericDriver(string key, double toValue, TransitionConfig co apply = v => { inner(v); onUpdate(v); }; } - IAnimationDriver driver = config.Type switch + IBmotionAnimationDriver driver = config.Type switch { - TransitionType.Spring => new SpringDriver(from, toValue, config, apply), - TransitionType.Inertia => new InertiaDriver(from, config, apply), - _ => new TweenDriver(from, toValue, config, apply), + BmotionTransitionType.Spring => new BmotionSpringDriver(from, toValue, config, apply), + BmotionTransitionType.Inertia => new BmotionInertiaDriver(from, config, apply), + _ => new BmotionTweenDriver(from, toValue, config, apply), }; _activeAnims[key] = driver; } - private void CreateColorDriver(string key, string toValue, TransitionConfig config) + private void CreateColorDriver(string key, string toValue, BmotionTransitionConfig config) { string from = StringValues.GetValueOrDefault(key, "rgba(0,0,0,0)"); - _activeAnims[key] = new ColorTweenDriver(from, toValue, config, v => ApplyString(key, v)); + _activeAnims[key] = new BmotionColorTweenDriver(from, toValue, config, v => ApplyString(key, v)); } - private void CreateNumericKeyframesDriver(string key, double[] frames, TransitionConfig config) + private void CreateNumericKeyframesDriver(string key, double[] frames, BmotionTransitionConfig config) { - bool isTransform = TransformComposer.IsTransformProp(key); + bool isTransform = BmotionTransformComposer.IsTransformProp(key); Action apply = isTransform ? v => ApplyTransform(key, v) : v => ApplyNumeric(key, v); - _activeAnims[key] = new NumericKeyframesDriver(frames, config, apply); + _activeAnims[key] = new BmotionNumericKeyframesDriver(frames, config, apply); } - private void CreateColorKeyframesDriver(string key, string[] frames, TransitionConfig config) + private void CreateColorKeyframesDriver(string key, string[] frames, BmotionTransitionConfig config) { - _activeAnims[key] = new ColorKeyframesDriver(frames, config, v => ApplyString(key, v)); + _activeAnims[key] = new BmotionColorKeyframesDriver(frames, config, v => ApplyString(key, v)); } // ── Value apply callbacks (mark dirty) ──────────────────────────────────── @@ -420,7 +418,7 @@ private static bool TryGetDoubleArray(object? value, out double[]? result) return false; } - private void CreateCssDimensionDriver(string key, string toValue, TransitionConfig config) + private void CreateCssDimensionDriver(string key, string toValue, BmotionTransitionConfig config) { // If both from and to are the same unit, interpolate numerically. // Otherwise just snap to the new value immediately. @@ -429,8 +427,8 @@ private void CreateCssDimensionDriver(string key, string toValue, TransitionConf TryParseCssDimension(fromRaw, out double fromNum, out string fromUnit) && string.Equals(fromUnit, toUnit, StringComparison.OrdinalIgnoreCase)) { - _activeAnims[key] = new TweenDriver(fromNum, toNum, config, - v => ApplyString(key, CssFormat.Num(v) + toUnit)); + _activeAnims[key] = new BmotionTweenDriver(fromNum, toNum, config, + v => ApplyString(key, BmotionCssFormat.Num(v) + toUnit)); } else { @@ -466,5 +464,5 @@ private static bool TryGetStringArray(object? value, out string[]? result) return false; } - private sealed record GestureLayer(Dictionary Values, TransitionConfig? Transition); + private sealed record GestureLayer(Dictionary Values, BmotionTransitionConfig? Transition); } diff --git a/src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs similarity index 91% rename from src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs index 6ef0023f3e..c8da2fce18 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/InertiaDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs @@ -1,12 +1,10 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// /// Exponential-decay inertia driver. Decelerates from an initial velocity toward /// an optional projected target, with optional bounds clamping. /// -internal sealed class InertiaDriver : IAnimationDriver +internal sealed class BmotionInertiaDriver : IBmotionAnimationDriver { private readonly double _start; private readonly double _projected; @@ -21,7 +19,7 @@ internal sealed class InertiaDriver : IAnimationDriver private double _startTs = -1; private bool _cancelled; - public InertiaDriver(double from, TransitionConfig config, Action apply) + public BmotionInertiaDriver(double from, BmotionTransitionConfig config, Action apply) { _start = from; _timeConstantSec = config.TimeConstant > 0 ? config.TimeConstant / 1000.0 : 1e-6; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs new file mode 100644 index 0000000000..e3e5c9210d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -0,0 +1,85 @@ +namespace Bit.Bmotion; +/// Keyframe animation driver for numeric (double) properties. +internal sealed class BmotionNumericKeyframesDriver : IBmotionAnimationDriver +{ + private readonly double[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly BmotionRepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private double[] _curFrames; + + public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig config, Action apply) + { + _frames = frames; + _curFrames = (double[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.Repeat == int.MaxValue; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + + // Per-segment easing: if ease is an array of length n-1, use one per segment; otherwise use same for all + _eases = new Func[n - 1]; + var globalEase = BmotionEasingFunctions.Get(config); + for (int i = 0; i < n - 1; i++) + _eases[i] = globalEase; + } + + public bool Tick(double timestamp) + { + if (_cancelled) { _apply(_frames[^1]); return true; } + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; + _apply(Interpolate(_curFrames, _times, _eases, t)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) + Array.Reverse(_curFrames); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() => _apply(_frames[^1]); + + private static double Interpolate(double[] frames, double[] times, Func[] eases, double t) + { + int n = frames.Length; + int seg = n - 2; + for (int i = 0; i < n - 1; i++) + { + if (t <= times[i + 1]) { seg = i; break; } + } + double segLen = times[seg + 1] - times[seg]; + double segT = segLen > 0 ? (t - times[seg]) / segLen : 1.0; + double easedT = eases[seg](Math.Min(segT, 1.0)); + return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs similarity index 89% rename from src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs index 44cc7c6eb6..c13d7a700b 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/SpringDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs @@ -1,13 +1,11 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// /// Semi-implicit Euler spring physics driver for numeric properties. /// Automatically subdivides each frame to maintain numerical stability for /// high-stiffness / high-damping configurations. /// -internal sealed class SpringDriver : IAnimationDriver +internal sealed class BmotionSpringDriver : IBmotionAnimationDriver { private double _target; private double _from; @@ -21,7 +19,7 @@ internal sealed class SpringDriver : IAnimationDriver private readonly double _maxSubDt; private readonly int _repeat; private readonly bool _isInfinite; - private readonly RepeatType _repeatType; + private readonly BmotionRepeatType _repeatType; private readonly Action _apply; private double _pos; @@ -32,7 +30,7 @@ internal sealed class SpringDriver : IAnimationDriver private int _iteration; private bool _cancelled; - public SpringDriver(double from, double to, TransitionConfig config, Action apply) + public BmotionSpringDriver(double from, double to, BmotionTransitionConfig config, Action apply) { _pos = _from = from; _target = to; @@ -44,7 +42,7 @@ public SpringDriver(double from, double to, TransitionConfig config, Action /// Builds a CSS transform string from a dictionary of individual transform components. /// Mirrors the JS buildTransformString function. /// -internal static class TransformComposer +internal static class BmotionTransformComposer { private static readonly HashSet _transformProps = new(StringComparer.OrdinalIgnoreCase) { @@ -28,32 +27,32 @@ public static string Build(Dictionary t) var parts = new List(8); if (t.TryGetValue("perspective", out double persp) && persp != 0) - parts.Add($"perspective({CssFormat.Num(persp)}px)"); + parts.Add($"perspective({BmotionCssFormat.Num(persp)}px)"); double x = t.GetValueOrDefault("x"); double y = t.GetValueOrDefault("y"); double z = t.GetValueOrDefault("z"); if (x != 0 || y != 0 || z != 0) parts.Add(z != 0 - ? $"translate3d({CssFormat.Num(x)}px,{CssFormat.Num(y)}px,{CssFormat.Num(z)}px)" - : $"translate({CssFormat.Num(x)}px,{CssFormat.Num(y)}px)"); + ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)" + : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); if (t.TryGetValue("scale", out double scale)) - parts.Add($"scale({CssFormat.Num(scale)})"); + parts.Add($"scale({BmotionCssFormat.Num(scale)})"); else { - if (t.TryGetValue("scaleX", out double sx) && sx != 1) parts.Add($"scaleX({CssFormat.Num(sx)})"); - if (t.TryGetValue("scaleY", out double sy) && sy != 1) parts.Add($"scaleY({CssFormat.Num(sy)})"); + if (t.TryGetValue("scaleX", out double sx) && sx != 1) parts.Add($"scaleX({BmotionCssFormat.Num(sx)})"); + if (t.TryGetValue("scaleY", out double sy) && sy != 1) parts.Add($"scaleY({BmotionCssFormat.Num(sy)})"); } // rotateZ / rotate aliases double rz = t.TryGetValue("rotateZ", out double rz2) ? rz2 : t.GetValueOrDefault("rotate"); - if (rz != 0) parts.Add($"rotate({CssFormat.Num(rz)}deg)"); - if (t.TryGetValue("rotateX", out double rx) && rx != 0) parts.Add($"rotateX({CssFormat.Num(rx)}deg)"); - if (t.TryGetValue("rotateY", out double ry) && ry != 0) parts.Add($"rotateY({CssFormat.Num(ry)}deg)"); + if (rz != 0) parts.Add($"rotate({BmotionCssFormat.Num(rz)}deg)"); + if (t.TryGetValue("rotateX", out double rx) && rx != 0) parts.Add($"rotateX({BmotionCssFormat.Num(rx)}deg)"); + if (t.TryGetValue("rotateY", out double ry) && ry != 0) parts.Add($"rotateY({BmotionCssFormat.Num(ry)}deg)"); - if (t.TryGetValue("skewX", out double skx) && skx != 0) parts.Add($"skewX({CssFormat.Num(skx)}deg)"); - if (t.TryGetValue("skewY", out double sky) && sky != 0) parts.Add($"skewY({CssFormat.Num(sky)}deg)"); + if (t.TryGetValue("skewX", out double skx) && skx != 0) parts.Add($"skewX({BmotionCssFormat.Num(skx)}deg)"); + if (t.TryGetValue("skewY", out double sky) && sky != 0) parts.Add($"skewY({BmotionCssFormat.Num(sky)}deg)"); return string.Join(" ", parts); } diff --git a/src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs similarity index 82% rename from src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs rename to src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs index 9a00683840..b51ce6ee63 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/TweenDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs @@ -1,9 +1,7 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// Tween (duration-based) animation driver for numeric properties. -internal sealed class TweenDriver : IAnimationDriver +internal sealed class BmotionTweenDriver : IBmotionAnimationDriver { private readonly double _to; private readonly double _durationMs; @@ -11,7 +9,7 @@ internal sealed class TweenDriver : IAnimationDriver private readonly Func _easeFn; private readonly int _repeat; private readonly bool _isInfinite; - private readonly RepeatType _repeatType; + private readonly BmotionRepeatType _repeatType; private readonly double _repeatDelayMs; private readonly Action _apply; @@ -21,13 +19,13 @@ internal sealed class TweenDriver : IAnimationDriver private double _curFrom; private double _curTo; - public TweenDriver(double from, double to, TransitionConfig config, Action apply) + public BmotionTweenDriver(double from, double to, BmotionTransitionConfig config, Action apply) { _curFrom = from; _curTo = _to = to; _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; - _easeFn = EasingFunctions.Get(config); + _easeFn = BmotionEasingFunctions.Get(config); _repeat = config.Repeat; _isInfinite = config.Repeat == int.MaxValue; _repeatType = config.RepeatType; @@ -54,7 +52,7 @@ public bool Tick(double timestamp) { if (!_isInfinite) _iteration++; _startTime = timestamp + _repeatDelayMs; - if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) + if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) (_curFrom, _curTo) = (_curTo, _curFrom); return false; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs similarity index 85% rename from src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs rename to src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs index 353e9bfe63..8fab5d113e 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/IAnimationDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs @@ -1,12 +1,11 @@ -namespace Bit.Bmotion.Engine; - +namespace Bit.Bmotion; /// /// Single animation driver interface. /// Each driver owns the callback that applies the animated value to -/// state dictionaries. +/// state dictionaries. /// Returns true from when the animation is complete. /// -internal interface IAnimationDriver +internal interface IBmotionAnimationDriver { /// /// Advance the animation to (milliseconds, matching performance.now()). diff --git a/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs deleted file mode 100644 index b925de945b..0000000000 --- a/src/Bmotion/Bit.Bmotion/Engine/KeyframesDriver.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Engine; - -/// Keyframe animation driver for numeric (double) properties. -internal sealed class NumericKeyframesDriver : IAnimationDriver -{ - private readonly double[] _frames; - private readonly double _durationMs; - private readonly double _delayMs; - private readonly double[] _times; - private readonly Func[] _eases; - private readonly int _repeat; - private readonly bool _isInfinite; - private readonly RepeatType _repeatType; - private readonly double _repeatDelayMs; - private readonly Action _apply; - - private double _startTime = -1; - private bool _cancelled; - private int _iteration; - private double[] _curFrames; - - public NumericKeyframesDriver(double[] frames, TransitionConfig config, Action apply) - { - _frames = frames; - _curFrames = (double[])frames.Clone(); - _durationMs = config.Duration * 1000; - _delayMs = config.Delay * 1000; - _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; - _repeatType = config.RepeatType; - _repeatDelayMs = config.RepeatDelay * 1000; - _apply = apply; - - int n = frames.Length; - _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); - - // Per-segment easing: if ease is an array of length n-1, use one per segment; otherwise use same for all - _eases = new Func[n - 1]; - var globalEase = EasingFunctions.Get(config); - for (int i = 0; i < n - 1; i++) - _eases[i] = globalEase; - } - - public bool Tick(double timestamp) - { - if (_cancelled) { _apply(_frames[^1]); return true; } - - if (_startTime < 0) _startTime = timestamp + _delayMs; - if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } - - double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; - _apply(Interpolate(_curFrames, _times, _eases, t)); - - if (t >= 1.0) - { - if (_isInfinite || _iteration < _repeat) - { - if (!_isInfinite) _iteration++; - _startTime = timestamp + _repeatDelayMs; - if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) - Array.Reverse(_curFrames); - return false; - } - return true; - } - return false; - } - - public void Cancel() => _cancelled = true; - - public void Complete() => _apply(_frames[^1]); - - private static double Interpolate(double[] frames, double[] times, Func[] eases, double t) - { - int n = frames.Length; - int seg = n - 2; - for (int i = 0; i < n - 1; i++) - { - if (t <= times[i + 1]) { seg = i; break; } - } - double segLen = times[seg + 1] - times[seg]; - double segT = segLen > 0 ? (t - times[seg]) / segLen : 1.0; - double easedT = eases[seg](Math.Min(segT, 1.0)); - return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT; - } -} - -/// Keyframe animation driver for CSS color string properties. -internal sealed class ColorKeyframesDriver : IAnimationDriver -{ - private readonly string[] _frames; - private readonly double _durationMs; - private readonly double _delayMs; - private readonly double[] _times; - private readonly Func[] _eases; - private readonly int _repeat; - private readonly bool _isInfinite; - private readonly RepeatType _repeatType; - private readonly double _repeatDelayMs; - private readonly Action _apply; - - private double _startTime = -1; - private bool _cancelled; - private int _iteration; - private string[] _curFrames; - - public ColorKeyframesDriver(string[] frames, TransitionConfig config, Action apply) - { - _frames = frames; - _curFrames = (string[])frames.Clone(); - _durationMs = config.Duration * 1000; - _delayMs = config.Delay * 1000; - _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; - _repeatType = config.RepeatType; - _repeatDelayMs = config.RepeatDelay * 1000; - _apply = apply; - - int n = frames.Length; - _times = config.Times ?? Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); - var globalEase = EasingFunctions.Get(config); - _eases = Enumerable.Repeat(globalEase, n - 1).ToArray(); - } - - public bool Tick(double timestamp) - { - if (_cancelled) { _apply(_frames[^1]); return true; } - - if (_startTime < 0) _startTime = timestamp + _delayMs; - if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } - - double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; - - int n = _curFrames.Length; - int seg = n - 2; - for (int i = 0; i < n - 1; i++) { if (t <= _times[i + 1]) { seg = i; break; } } - double segLen = _times[seg + 1] - _times[seg]; - double segT = segLen > 0 ? (t - _times[seg]) / segLen : 1.0; - double easedT = _eases[seg](Math.Min(segT, 1.0)); - _apply(ColorInterpolator.Lerp(_curFrames[seg], _curFrames[seg + 1], easedT)); - - if (t >= 1.0) - { - if (_isInfinite || _iteration < _repeat) - { - if (!_isInfinite) _iteration++; - _startTime = timestamp + _repeatDelayMs; - if (_repeatType == RepeatType.Mirror || _repeatType == RepeatType.Reverse) - Array.Reverse(_curFrames); - return false; - } - return true; - } - return false; - } - - public void Cancel() => _cancelled = true; - - public void Complete() => _apply(_frames[^1]); -} diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs new file mode 100644 index 0000000000..05da7f517b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs @@ -0,0 +1,12 @@ +namespace Bit.Bmotion; + +/// DOM bounding rect returned by getBoundingRect in JS. +public sealed class BmotionBoundingRect +{ + public double X { get; set; } + public double Y { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public double Top { get; set; } + public double Left { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs similarity index 88% rename from src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs rename to src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs index 43ff26846a..fc26218cd6 100644 --- a/src/Bmotion/Bit.Bmotion/Interop/MotionInterop.cs +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs @@ -1,23 +1,21 @@ -using Bit.Bmotion.Models; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; -namespace Bit.Bmotion.Interop; - +namespace Bit.Bmotion; /// /// Slim C# wrapper around the browser-API bridge in BitBmotion.js. /// Only calls browser-native APIs; all animation logic lives in the C# engine. /// -public sealed class MotionInterop : IAsyncDisposable +public sealed class BmotionInterop : IAsyncDisposable { private readonly Lazy> _moduleTask; - public MotionInterop(IJSRuntime js) + public BmotionInterop(IJSRuntime js) { IsInProcess = js is IJSInProcessRuntime; _moduleTask = new Lazy>( () => js.InvokeAsync( - "import", "./_content/Bit.Bmotion/BitBmotion.js").AsTask()); + "import", "./_content/Bit.Bmotion/bit-bmotion.js").AsTask()); } /// @@ -88,7 +86,7 @@ public async ValueTask ObserveViewportAsync( new Dictionary { ["once"] = once, ["margin"] = "0px", ["threshold"] = 0.0 }); public async ValueTask ObserveViewportWithOptionsAsync( - string elementId, DotNetObjectReference dotnetRef, ViewportOptions options) where T : class + string elementId, DotNetObjectReference dotnetRef, BmotionViewportOptions options) where T : class => await (await Module()).InvokeVoidAsync("observeViewport", elementId, dotnetRef, options.ToJsObject()); public async ValueTask UnobserveViewportAsync(string elementId) @@ -100,8 +98,8 @@ public async ValueTask UnobserveViewportAsync(string elementId) // ── FLIP layout ─────────────────────────────────────────────────────────── /// Returns the element's current bounding rect (for C# FLIP delta computation). - public async ValueTask GetBoundingRectAsync(string elementId) - => await (await Module()).InvokeAsync("getBoundingRect", elementId); + public async ValueTask GetBoundingRectAsync(string elementId) + => await (await Module()).InvokeAsync("getBoundingRect", elementId); /// Run a FLIP animation via the Web Animations API. public async ValueTask PlayWaapiFlipAsync( @@ -126,7 +124,7 @@ public async ValueTask UnobserveScrollAsync(string key) /// /// Resolves all DOM elements matching , assigns stable IDs - /// if needed, and returns those IDs so the can address them. + /// if needed, and returns those IDs so the can address them. /// public async ValueTask ResolveOrRegisterBySelectorAsync(string selector) => await (await Module()).InvokeAsync("resolveOrRegisterBySelector", selector); @@ -146,14 +144,3 @@ public async ValueTask DisposeAsync() await (await Module()).DisposeAsync(); } } - -/// DOM bounding rect returned by getBoundingRect in JS. -public sealed class BoundingRect -{ - public double X { get; set; } - public double Y { get; set; } - public double Width { get; set; } - public double Height { get; set; } - public double Top { get; set; } - public double Left { get; set; } -} diff --git a/src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs similarity index 80% rename from src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs index 8310d8a2da..e2a203dccd 100644 --- a/src/Bmotion/Bit.Bmotion/Models/AnimationProps.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs @@ -1,12 +1,10 @@ -namespace Bit.Bmotion.Models; - -using Bit.Bmotion.Engine; +namespace Bit.Bmotion; /// /// Describes a set of animatable CSS / transform properties - the "what" of an animation. /// Assign to Initial, Animate, Exit, WhileHover, WhileTap, etc. /// -public class AnimationProps +public class BmotionAnimationProps { // ── Transform properties ────────────────────────────────────────────────── public double? X { get; set; } @@ -64,7 +62,7 @@ public class AnimationProps /// When a key is present here it takes precedence over the single-value property. /// /// - /// new AnimationProps + /// new BmotionAnimationProps /// { /// Keyframes = new() /// { @@ -137,24 +135,24 @@ internal string ToCssStyleString() { double x = X ?? 0, y = Y ?? 0, z = Z ?? 0; if (z != 0) - transforms.Add($"translate3d({CssFormat.Num(x)}px,{CssFormat.Num(y)}px,{CssFormat.Num(z)}px)"); + transforms.Add($"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)"); else - transforms.Add($"translate({CssFormat.Num(x)}px,{CssFormat.Num(y)}px)"); + transforms.Add($"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); } - if (Scale.HasValue) transforms.Add($"scale({CssFormat.Num(Scale.Value)})"); - if (ScaleX.HasValue) transforms.Add($"scaleX({CssFormat.Num(ScaleX.Value)})"); - if (ScaleY.HasValue) transforms.Add($"scaleY({CssFormat.Num(ScaleY.Value)})"); + if (Scale.HasValue) transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + if (ScaleX.HasValue) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); if (Rotate.HasValue || RotateZ.HasValue) - transforms.Add($"rotate({CssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); - if (RotateX.HasValue) transforms.Add($"rotateX({CssFormat.Num(RotateX.Value)}deg)"); - if (RotateY.HasValue) transforms.Add($"rotateY({CssFormat.Num(RotateY.Value)}deg)"); - if (SkewX.HasValue) transforms.Add($"skewX({CssFormat.Num(SkewX.Value)}deg)"); - if (SkewY.HasValue) transforms.Add($"skewY({CssFormat.Num(SkewY.Value)}deg)"); - if (Perspective.HasValue) transforms.Insert(0, $"perspective({CssFormat.Num(Perspective.Value)}px)"); + transforms.Add($"rotate({BmotionCssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); + if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); + if (Perspective.HasValue) transforms.Insert(0, $"perspective({BmotionCssFormat.Num(Perspective.Value)}px)"); if (transforms.Count > 0) sb.Append($"transform:{string.Join(" ", transforms)};"); - if (Opacity.HasValue) sb.Append($"opacity:{CssFormat.Num(Opacity.Value)};"); + if (Opacity.HasValue) sb.Append($"opacity:{BmotionCssFormat.Num(Opacity.Value)};"); if (BackgroundColor != null) sb.Append($"background-color:{BackgroundColor};"); if (Color != null) sb.Append($"color:{Color};"); if (BorderColor != null) sb.Append($"border-color:{BorderColor};"); @@ -170,8 +168,8 @@ internal string ToCssStyleString() double clamped = Math.Max(0, Math.Min(1, PathLength.Value)); double spacing = PathSpacing ?? 1.0; double offset = PathOffset ?? 0.0; - sb.Append($"stroke-dasharray:{CssFormat.Num(clamped)} {CssFormat.Num(spacing)};"); - sb.Append($"stroke-dashoffset:{CssFormat.Num(1 - clamped - offset)};"); + sb.Append($"stroke-dasharray:{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)};"); + sb.Append($"stroke-dashoffset:{BmotionCssFormat.Num(1 - clamped - offset)};"); } if (CssVars != null) @@ -193,25 +191,25 @@ internal Dictionary ToCssStyleDictionary() var d = new Dictionary(); var transforms = new List(); - if (Perspective.HasValue) transforms.Add($"perspective({CssFormat.Num(Perspective.Value)}px)"); + if (Perspective.HasValue) transforms.Add($"perspective({BmotionCssFormat.Num(Perspective.Value)}px)"); if (X.HasValue || Y.HasValue || Z.HasValue) { double x = X ?? 0, y = Y ?? 0, z = Z ?? 0; transforms.Add(z != 0 - ? $"translate3d({CssFormat.Num(x)}px,{CssFormat.Num(y)}px,{CssFormat.Num(z)}px)" - : $"translate({CssFormat.Num(x)}px,{CssFormat.Num(y)}px)"); + ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)" + : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); } - if (Scale.HasValue) transforms.Add($"scale({CssFormat.Num(Scale.Value)})"); - if (ScaleX.HasValue) transforms.Add($"scaleX({CssFormat.Num(ScaleX.Value)})"); - if (ScaleY.HasValue) transforms.Add($"scaleY({CssFormat.Num(ScaleY.Value)})"); - if (Rotate.HasValue || RotateZ.HasValue) transforms.Add($"rotate({CssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); - if (RotateX.HasValue) transforms.Add($"rotateX({CssFormat.Num(RotateX.Value)}deg)"); - if (RotateY.HasValue) transforms.Add($"rotateY({CssFormat.Num(RotateY.Value)}deg)"); - if (SkewX.HasValue) transforms.Add($"skewX({CssFormat.Num(SkewX.Value)}deg)"); - if (SkewY.HasValue) transforms.Add($"skewY({CssFormat.Num(SkewY.Value)}deg)"); + if (Scale.HasValue) transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + if (ScaleX.HasValue) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); + if (Rotate.HasValue || RotateZ.HasValue) transforms.Add($"rotate({BmotionCssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); + if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); if (transforms.Count > 0) d["transform"] = string.Join(" ", transforms); - if (Opacity.HasValue) d["opacity"] = CssFormat.Num(Opacity.Value); + if (Opacity.HasValue) d["opacity"] = BmotionCssFormat.Num(Opacity.Value); if (BackgroundColor != null) d["backgroundColor"] = BackgroundColor; if (Color != null) d["color"] = Color; if (BorderColor != null) d["borderColor"] = BorderColor; @@ -227,8 +225,8 @@ internal Dictionary ToCssStyleDictionary() double clamped = Math.Max(0, Math.Min(1, PathLength.Value)); double spacing = PathSpacing ?? 1.0; double offset = PathOffset ?? 0.0; - d["strokeDasharray"] = $"{CssFormat.Num(clamped)} {CssFormat.Num(spacing)}"; - d["strokeDashoffset"] = CssFormat.Num(1 - clamped - offset); + d["strokeDasharray"] = $"{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)}"; + d["strokeDashoffset"] = BmotionCssFormat.Num(1 - clamped - offset); } if (CssVars != null) @@ -239,12 +237,12 @@ internal Dictionary ToCssStyleDictionary() } /// - /// Structural value comparison used by to decide whether an + /// Structural value comparison used by to decide whether an /// Animate target actually changed between renders. This avoids re-triggering an /// animation (and OnAnimationStart) on every unrelated re-render when a consumer writes - /// the idiomatic Animate="@(new AnimationProps { ... })" (a fresh reference each render). + /// the idiomatic Animate="@(new BmotionAnimationProps { ... })" (a fresh reference each render). /// - internal bool ValueEquals(AnimationProps? o) + internal bool ValueEquals(BmotionAnimationProps? o) { if (o is null) return false; if (ReferenceEquals(this, o)) return true; diff --git a/src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs similarity index 62% rename from src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs index 8dcf6f410e..da01d9dd2f 100644 --- a/src/Bmotion/Bit.Bmotion/Models/AnimationTarget.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs @@ -1,16 +1,15 @@ -namespace Bit.Bmotion.Models; - +namespace Bit.Bmotion; /// /// Union type for animation target parameters (Initial, Animate, Exit, WhileHover, …). -/// Can be implicitly constructed from , a variant name string, +/// Can be implicitly constructed from , a variant name string, /// or false to disable the target entirely. /// -public sealed class AnimationTarget +public sealed class BmotionAnimationTarget { /// Direct set of animation properties. - public AnimationProps? Props { get; private init; } + public BmotionAnimationProps? Props { get; private init; } - /// Name of a variant defined in the nearest Motion ancestor's Variants dictionary. + /// Name of a variant defined in the nearest Bmotion ancestor's Variants dictionary. public string? Variant { get; private init; } /// When true this target is explicitly disabled (e.g. Initial="false"). @@ -21,10 +20,10 @@ public sealed class AnimationTarget /// /// Value-based equivalence between two targets, used for change detection. - /// Two prop targets are equivalent when their values match; + /// Two prop targets are equivalent when their values match; /// two variant targets when they name the same variant. /// - internal static bool AreEquivalent(AnimationTarget? a, AnimationTarget? b) + internal static bool AreEquivalent(BmotionAnimationTarget? a, BmotionAnimationTarget? b) { if (ReferenceEquals(a, b)) return true; if (a is null || b is null) return false; @@ -36,12 +35,12 @@ internal static bool AreEquivalent(AnimationTarget? a, AnimationTarget? b) } // ── Implicit conversions ────────────────────────────────────────────────── - public static implicit operator AnimationTarget(AnimationProps props) + public static implicit operator BmotionAnimationTarget(BmotionAnimationProps props) => new() { Props = props }; - public static implicit operator AnimationTarget(string variant) + public static implicit operator BmotionAnimationTarget(string variant) => new() { Variant = variant }; - public static implicit operator AnimationTarget(bool value) - => value ? new() { Props = new AnimationProps() } : new() { IsDisabled = true }; + public static implicit operator BmotionAnimationTarget(bool value) + => value ? new() { Props = new BmotionAnimationProps() } : new() { IsDisabled = true }; } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs new file mode 100644 index 0000000000..149d172ce3 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs @@ -0,0 +1,3 @@ +namespace Bit.Bmotion; + +public enum BmotionDragAxis { Both, X, Y } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs new file mode 100644 index 0000000000..2b6680dbc5 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs @@ -0,0 +1,28 @@ +namespace Bit.Bmotion; + +public class BmotionDragConstraints +{ + public double? Left { get; set; } + public double? Right { get; set; } + public double? Top { get; set; } + public double? Bottom { get; set; } + + public static BmotionDragConstraints Horizontal(double left, double right) + => new() { Left = left, Right = right }; + + public static BmotionDragConstraints Vertical(double top, double bottom) + => new() { Top = top, Bottom = bottom }; + + public static BmotionDragConstraints Box(double left, double right, double top, double bottom) + => new() { Left = left, Right = right, Top = top, Bottom = bottom }; + + internal object ToJsObject() + { + var d = new Dictionary(); + if (Left.HasValue) d["left"] = Left.Value; + if (Right.HasValue) d["right"] = Right.Value; + if (Top.HasValue) d["top"] = Top.Value; + if (Bottom.HasValue) d["bottom"] = Bottom.Value; + return d; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs new file mode 100644 index 0000000000..a943f4589a --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs @@ -0,0 +1,45 @@ +namespace Bit.Bmotion; +/// +/// Options for the drag gesture on a Bmotion element. +/// +public class BmotionDragOptions +{ + /// Restrict drag to a single axis. Null = both axes. + public BmotionDragAxis Axis { get; set; } = BmotionDragAxis.Both; + + /// + /// Constraint bounds (in px relative to the element's resting position). + /// Null = unconstrained. + /// + public BmotionDragConstraints? Constraints { get; set; } + + /// + /// Elasticity when the drag exceeds constraints (0 = rigid, 1 = fully elastic). + /// Default: 0.35. + /// + public double Elastic { get; set; } = 0.35; + + /// + /// Whether to apply momentum / inertia after releasing. Default: true. + /// + public bool Momentum { get; set; } = true; + + /// + /// Transition applied when snapping back to constraints after release. + /// Defaults to a spring. + /// + public BmotionTransitionConfig? SnapTransition { get; set; } + + /// + /// If true, the draggable element will spring back to its center (origin) when released. + /// Default: false. + /// + public bool SnapToOrigin { get; set; } + + /// + /// Locks drag to the dominant movement axis once detected. + /// For example, moving mostly horizontally will lock drag to x only. + /// Default: false. + /// + public bool DirectionLock { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs new file mode 100644 index 0000000000..232e75e022 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs @@ -0,0 +1,10 @@ +namespace Bit.Bmotion; + +public enum BmotionEasing +{ + Linear, + EaseIn, EaseOut, EaseInOut, + CircIn, CircOut, CircInOut, + BackIn, BackOut, BackInOut, + Anticipate +} diff --git a/src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs similarity index 57% rename from src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs index 8ec23e4de5..1789815107 100644 --- a/src/Bmotion/Bit.Bmotion/Models/MotionVariants.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs @@ -1,35 +1,34 @@ -namespace Bit.Bmotion.Models; - +namespace Bit.Bmotion; /// /// A named set of animation states (variants) that can be referenced by name on -/// any Motion component. Children automatically inherit the active variant name +/// any Bmotion component. Children automatically inherit the active variant name /// unless they define their own. /// -public class MotionVariants +public class BmotionMotionVariants { - private readonly Dictionary _variants = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _variants = new(StringComparer.OrdinalIgnoreCase); /// /// Adds a variant by name. If a variant with the same name already exists /// (case-insensitive) it is replaced. /// - public MotionVariants Add(string name, AnimationProps props) + public BmotionMotionVariants Add(string name, BmotionAnimationProps props) { _variants[name] = props; return this; } - public AnimationProps? Get(string name) + public BmotionAnimationProps? Get(string name) => _variants.TryGetValue(name, out var v) ? v : null; public bool Contains(string name) => _variants.ContainsKey(name); - public AnimationProps? this[string name] => Get(name); + public BmotionAnimationProps? this[string name] => Get(name); // ── Builder shorthand ───────────────────────────────────────────────────── - public static MotionVariants Create(params (string name, AnimationProps props)[] entries) + public static BmotionMotionVariants Create(params (string name, BmotionAnimationProps props)[] entries) { - var mv = new MotionVariants(); + var mv = new BmotionMotionVariants(); foreach (var (name, props) in entries) mv.Add(name, props); return mv; diff --git a/src/Bmotion/Bit.Bmotion/Models/PanInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs similarity index 51% rename from src/Bmotion/Bit.Bmotion/Models/PanInfo.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs index d52872179d..d037d0be0f 100644 --- a/src/Bmotion/Bit.Bmotion/Models/PanInfo.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs @@ -1,27 +1,20 @@ -namespace Bit.Bmotion.Models; +namespace Bit.Bmotion; /// /// Information about a pan gesture provided to OnPan callbacks. /// Matches the Framer Motion pan event info shape. /// -public class PanInfo +public class BmotionPanInfo { /// Current pointer position relative to the document. - public required PointInfo Point { get; init; } + public required BmotionPointInfo Point { get; init; } /// Distance moved since the last event. - public required PointInfo Delta { get; init; } + public required BmotionPointInfo Delta { get; init; } /// Total distance moved since the pan gesture started. - public required PointInfo Offset { get; init; } + public required BmotionPointInfo Offset { get; init; } /// Current velocity of the pointer (pixels per second). - public required PointInfo Velocity { get; init; } -} - -/// A 2-D point with and components. -public class PointInfo -{ - public double X { get; set; } - public double Y { get; set; } + public required BmotionPointInfo Velocity { get; init; } } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs new file mode 100644 index 0000000000..aee0a99014 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs @@ -0,0 +1,8 @@ +namespace Bit.Bmotion; + +/// A 2-D point with and components. +public class BmotionPointInfo +{ + public double X { get; set; } + public double Y { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs new file mode 100644 index 0000000000..cb3396da9f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs @@ -0,0 +1,3 @@ +namespace Bit.Bmotion; + +public enum BmotionRepeatType { Loop, Mirror, Reverse } diff --git a/src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs similarity index 79% rename from src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs index 9792a4173c..cd33abb69e 100644 --- a/src/Bmotion/Bit.Bmotion/Models/ScrollInfo.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs @@ -1,7 +1,7 @@ -namespace Bit.Bmotion.Models; +namespace Bit.Bmotion; -/// Data returned by the on each scroll event. -public class ScrollInfo +/// Data returned by the on each scroll event. +public class BmotionScrollInfo { /// Horizontal scroll offset in pixels. public double ScrollX { get; init; } diff --git a/src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs similarity index 80% rename from src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs index 3bee2a0158..d4cc23b2d1 100644 --- a/src/Bmotion/Bit.Bmotion/Models/TransitionConfig.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -1,11 +1,10 @@ -namespace Bit.Bmotion.Models; - +namespace Bit.Bmotion; /// Controls how a value transitions from one state to another. -public class TransitionConfig +public class BmotionTransitionConfig { // ── Type ───────────────────────────────────────────────────────────────── /// Animation driver: Tween, Spring, or Inertia. Default: Tween. - public TransitionType Type { get; set; } = TransitionType.Tween; + public BmotionTransitionType Type { get; set; } = BmotionTransitionType.Tween; // ── Tween ───────────────────────────────────────────────────────────────── /// Duration in seconds. Default: 0.3. @@ -14,8 +13,8 @@ public class TransitionConfig /// Delay before animation starts, in seconds. Default: 0. public double Delay { get; set; } = 0; - /// Named easing preset. See . Default: EaseOut. - public Easing Ease { get; set; } = Easing.EaseOut; + /// Named easing preset. See . Default: EaseOut. + public BmotionEasing Ease { get; set; } = BmotionEasing.EaseOut; /// /// Custom cubic-bezier as [x1, y1, x2, y2]. Overrides when set. @@ -27,7 +26,7 @@ public class TransitionConfig public int Repeat { get; set; } = 0; /// How to repeat: Loop, Mirror (ping-pong), or Reverse. - public RepeatType RepeatType { get; set; } = RepeatType.Loop; + public BmotionRepeatType RepeatType { get; set; } = BmotionRepeatType.Loop; /// Delay between repetitions, in seconds. public double RepeatDelay { get; set; } = 0; @@ -103,9 +102,9 @@ public class TransitionConfig // ── Per-property overrides ──────────────────────────────────────────────── /// /// Override transition for specific properties, e.g. - /// Properties = new { ["opacity"] = new TransitionConfig { Duration = 0.1 } } + /// Properties = new { ["opacity"] = new BmotionTransitionConfig { Duration = 0.1 } } /// - public Dictionary? Properties { get; set; } + public Dictionary? Properties { get; set; } /// /// Called on every animation frame with the latest interpolated value. @@ -118,10 +117,10 @@ public class TransitionConfig /// /// Creates a deep copy of this configuration. Used internally when the library /// needs to derive a variant of a transition (e.g. applying a global - /// scale or a stagger delay) + /// scale or a stagger delay) /// without mutating or partially losing the original's fields. /// - public TransitionConfig Clone() => new() + public BmotionTransitionConfig Clone() => new() { Type = Type, Duration = Duration, @@ -152,30 +151,30 @@ public class TransitionConfig OnUpdate = OnUpdate, }; - private static Dictionary? CloneProperties( - Dictionary? source) + private static Dictionary? CloneProperties( + Dictionary? source) { if (source is null) return null; - var copy = new Dictionary(source.Count); + var copy = new Dictionary(source.Count); foreach (var kv in source) copy[kv.Key] = kv.Value.Clone(); return copy; } // ── Factory helpers ─────────────────────────────────────────────────────── - public static TransitionConfig Spring(double stiffness = 100, double damping = 10, double mass = 1) - => new() { Type = TransitionType.Spring, Stiffness = stiffness, Damping = damping, Mass = mass }; + public static BmotionTransitionConfig Spring(double stiffness = 100, double damping = 10, double mass = 1) + => new() { Type = BmotionTransitionType.Spring, Stiffness = stiffness, Damping = damping, Mass = mass }; /// /// Duration-based spring using intuitive (0 = no bounce, 1 = very bouncy) /// and parameters. Stiffness and damping are derived automatically. /// - public static TransitionConfig BounceSpring(double duration = 0.5, double bounce = 0.25, double mass = 1) + public static BmotionTransitionConfig BounceSpring(double duration = 0.5, double bounce = 0.25, double mass = 1) { var (stiffness, damping) = SpringFromBounce(duration, bounce, mass); return new() { - Type = TransitionType.Spring, + Type = BmotionTransitionType.Spring, Duration = duration, Bounce = bounce, VisualDuration = duration, @@ -185,11 +184,11 @@ public static TransitionConfig BounceSpring(double duration = 0.5, double bounce }; } - public static TransitionConfig Tween(double duration = 0.3, Easing ease = Easing.EaseOut) - => new() { Type = TransitionType.Tween, Duration = duration, Ease = ease }; + public static BmotionTransitionConfig Tween(double duration = 0.3, BmotionEasing ease = BmotionEasing.EaseOut) + => new() { Type = BmotionTransitionType.Tween, Duration = duration, Ease = ease }; - public static TransitionConfig Inertia(double velocity = 0, double timeConstant = 700) - => new() { Type = TransitionType.Inertia, InertiaVelocity = velocity, TimeConstant = timeConstant }; + public static BmotionTransitionConfig Inertia(double velocity = 0, double timeConstant = 700) + => new() { Type = BmotionTransitionType.Inertia, InertiaVelocity = velocity, TimeConstant = timeConstant }; /// /// Derives (stiffness, damping) from Framer-Motion-compatible @@ -205,18 +204,3 @@ internal static (double stiffness, double damping) SpringFromBounce( return (Math.Max(omega0 * omega0 * mass, 0.001), Math.Max(2.0 * zeta * omega0 * mass, 0.001)); } } - -// ── Enumerations ────────────────────────────────────────────────────────────── - -public enum TransitionType { Tween, Spring, Inertia, Keyframes } - -public enum Easing -{ - Linear, - EaseIn, EaseOut, EaseInOut, - CircIn, CircOut, CircInOut, - BackIn, BackOut, BackInOut, - Anticipate -} - -public enum RepeatType { Loop, Mirror, Reverse } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs new file mode 100644 index 0000000000..9122b72b8b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs @@ -0,0 +1,3 @@ +namespace Bit.Bmotion; + +public enum BmotionTransitionType { Tween, Spring, Inertia, Keyframes } diff --git a/src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs similarity index 91% rename from src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs rename to src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs index 9e4698effa..92db2dce5a 100644 --- a/src/Bmotion/Bit.Bmotion/Models/ViewportOptions.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs @@ -1,10 +1,9 @@ -namespace Bit.Bmotion.Models; - +namespace Bit.Bmotion; /// -/// Options that control how a element is tracked within the viewport +/// Options that control how a element is tracked within the viewport /// for WhileInView and OnViewportEnter/OnViewportLeave animations. /// -public class ViewportOptions +public class BmotionViewportOptions { /// /// If true, once the element enters the viewport the animation will not diff --git a/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs b/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs deleted file mode 100644 index 36ac15e3f2..0000000000 --- a/src/Bmotion/Bit.Bmotion/Models/DragOptions.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace Bit.Bmotion.Models; - -/// -/// Options for the drag gesture on a Motion element. -/// -public class DragOptions -{ - /// Restrict drag to a single axis. Null = both axes. - public DragAxis Axis { get; set; } = DragAxis.Both; - - /// - /// Constraint bounds (in px relative to the element's resting position). - /// Null = unconstrained. - /// - public DragConstraints? Constraints { get; set; } - - /// - /// Elasticity when the drag exceeds constraints (0 = rigid, 1 = fully elastic). - /// Default: 0.35. - /// - public double Elastic { get; set; } = 0.35; - - /// - /// Whether to apply momentum / inertia after releasing. Default: true. - /// - public bool Momentum { get; set; } = true; - - /// - /// Transition applied when snapping back to constraints after release. - /// Defaults to a spring. - /// - public TransitionConfig? SnapTransition { get; set; } - - /// - /// If true, the draggable element will spring back to its center (origin) when released. - /// Default: false. - /// - public bool SnapToOrigin { get; set; } - - /// - /// Locks drag to the dominant movement axis once detected. - /// For example, moving mostly horizontally will lock drag to x only. - /// Default: false. - /// - public bool DirectionLock { get; set; } -} - -public class DragConstraints -{ - public double? Left { get; set; } - public double? Right { get; set; } - public double? Top { get; set; } - public double? Bottom { get; set; } - - public static DragConstraints Horizontal(double left, double right) - => new() { Left = left, Right = right }; - - public static DragConstraints Vertical(double top, double bottom) - => new() { Top = top, Bottom = bottom }; - - public static DragConstraints Box(double left, double right, double top, double bottom) - => new() { Left = left, Right = right, Top = top, Bottom = bottom }; - - internal object ToJsObject() - { - var d = new Dictionary(); - if (Left.HasValue) d["left"] = Left.Value; - if (Right.HasValue) d["right"] = Right.Value; - if (Top.HasValue) d["top"] = Top.Value; - if (Bottom.HasValue) d["bottom"] = Bottom.Value; - return d; - } -} - -public enum DragAxis { Both, X, Y } diff --git a/src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs similarity index 70% rename from src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs rename to src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs index 7efb3cf40a..2809d299e0 100644 --- a/src/Bmotion/Bit.Bmotion/Services/MotionAnimateService.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs @@ -1,36 +1,32 @@ -using Bit.Bmotion.Engine; -using Bit.Bmotion.Interop; -using Bit.Bmotion.Models; using Microsoft.AspNetCore.Components; -namespace Bit.Bmotion.Services; - +namespace Bit.Bmotion; /// /// Provides a method-based animation API analogous to the animate() function in /// motion.dev. /// /// Elements are identified by a CSS selector string or a Blazor . -/// They do not need to be wrapped in a <Motion> component. +/// They do not need to be wrapped in a <Bmotion> component. /// /// /// /// /// // By CSS selector -/// var controls = await Motion.AnimateAsync(".box", new AnimationProps { X = 100, Opacity = 1 }); +/// var controls = await Motion.AnimateAsync(".box", new BmotionAnimationProps { X = 100, Opacity = 1 }); /// await controls; // wait for completion /// /// // By ElementReference captured via @ref -/// var controls = await Motion.AnimateAsync(myRef, new AnimationProps { Scale = 1.2 }, -/// TransitionConfig.Spring()); +/// var controls = await Motion.AnimateAsync(myRef, new BmotionAnimationProps { Scale = 1.2 }, +/// BmotionTransitionConfig.Spring()); /// controls.Stop(); // cancel early /// /// -public sealed class MotionAnimateService +public sealed class BmotionAnimateService { - private readonly AnimationEngine _engine; - private readonly MotionInterop _interop; + private readonly BmotionAnimationEngine _engine; + private readonly BmotionInterop _interop; - public MotionAnimateService(AnimationEngine engine, MotionInterop interop) + public BmotionAnimateService(BmotionAnimationEngine engine, BmotionInterop interop) { _engine = engine; _interop = interop; @@ -47,15 +43,15 @@ public MotionAnimateService(AnimationEngine engine, MotionInterop interop) /// Target animation properties. /// /// Optional transition configuration (easing, duration, spring parameters, etc.). - /// Falls back to the global default when omitted. + /// Falls back to the global default when omitted. /// /// - /// An that can be awaited or stopped early. + /// An that can be awaited or stopped early. /// - public async ValueTask AnimateAsync( + public async ValueTask AnimateAsync( string selector, - AnimationProps keyframes, - TransitionConfig? transition = null) + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition = null) { var ids = await _interop.ResolveOrRegisterBySelectorAsync(selector); return StartAnimations(ids, keyframes, transition); @@ -71,12 +67,12 @@ public async ValueTask AnimateAsync( /// Target animation properties. /// Optional transition configuration. /// - /// An that can be awaited or stopped early. + /// An that can be awaited or stopped early. /// - public async ValueTask AnimateAsync( + public async ValueTask AnimateAsync( ElementReference elementReference, - AnimationProps keyframes, - TransitionConfig? transition = null) + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition = null) { var id = await _interop.ResolveOrRegisterByRefAsync(elementReference); return StartAnimations([id], keyframes, transition); @@ -84,14 +80,14 @@ public async ValueTask AnimateAsync( // ──────────────────────────────────────────────────────────────────────────── - private AnimationControls StartAnimations( + private BmotionAnimationControls StartAnimations( string[] elementIds, - AnimationProps keyframes, - TransitionConfig? transition) + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition) { var values = keyframes.ToJsDictionary(); - // Only the elements we register here (i.e. not already owned by a ) are ours to + // Only the elements we register here (i.e. not already owned by a ) are ours to // clean up afterwards, so the engine's element table doesn't grow unbounded over time. var ours = new List(); foreach (var id in elementIds) @@ -120,6 +116,6 @@ private AnimationControls StartAnimations( }, TaskScheduler.Default); } - return new AnimationControls(elementIds, _engine, completion); + return new BmotionAnimationControls(elementIds, _engine, completion); } } diff --git a/src/Bmotion/Bit.Bmotion/Services/AnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs similarity index 64% rename from src/Bmotion/Bit.Bmotion/Services/AnimationController.cs rename to src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs index b7246a906c..cbd6bbb4a4 100644 --- a/src/Bmotion/Bit.Bmotion/Services/AnimationController.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs @@ -1,24 +1,21 @@ -using Bit.Bmotion.Engine; -using Bit.Bmotion.Models; - -namespace Bit.Bmotion.Services; +namespace Bit.Bmotion; /// /// Programmatic animation controller. /// Analogous to Framer Motion's useAnimate(). -/// Obtain via DI (@inject AnimationController) and bind to an element ID. -/// All animation math runs in the C# . +/// Obtain via DI (@inject BmotionAnimationController) and bind to an element ID. +/// All animation math runs in the C# . /// -public sealed class AnimationController +public sealed class BmotionAnimationController { - private readonly AnimationEngine _engine; + private readonly BmotionAnimationEngine _engine; private string? _elementId; - public AnimationController(AnimationEngine engine) => _engine = engine; + public BmotionAnimationController(BmotionAnimationEngine engine) => _engine = engine; /// /// Bind by element ID. Ensures the element is registered with the engine so the controller - /// works even when the target isn't wrapped in a <Motion> component. + /// works even when the target isn't wrapped in a <Bmotion> component. /// public void BindTo(string elementId) { @@ -27,21 +24,21 @@ public void BindTo(string elementId) } /// Animate the bound element to the given props (fire-and-forget). - public async ValueTask AnimateAsync(AnimationProps props, TransitionConfig? transition = null) + public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { if (_elementId == null) return; await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition); } /// Animate and await completion. - public async ValueTask AnimateAwaitAsync(AnimationProps props, TransitionConfig? transition = null) + public async ValueTask AnimateAwaitAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { if (_elementId == null) return; await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition); } /// Instantly set props without animation. - public void Set(AnimationProps props) + public void Set(BmotionAnimationProps props) { if (_elementId == null) return; _engine.SetInstant(_elementId, props.ToJsDictionary()); diff --git a/src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs similarity index 72% rename from src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs rename to src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs index 0736fe2f8e..499c1cf3a0 100644 --- a/src/Bmotion/Bit.Bmotion/Services/AnimationControls.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs @@ -1,20 +1,18 @@ using System.Runtime.CompilerServices; -using Bit.Bmotion.Engine; - -namespace Bit.Bmotion.Services; +namespace Bit.Bmotion; /// /// Controls for an in-flight programmatic animation started by -/// . +/// . /// The object is directly awaitable - await controls waits for the animation to complete. /// -public sealed class AnimationControls +public sealed class BmotionAnimationControls { private readonly IReadOnlyList _elementIds; - private readonly AnimationEngine _engine; + private readonly BmotionAnimationEngine _engine; private readonly Task _completion; - internal AnimationControls(IReadOnlyList elementIds, AnimationEngine engine, Task completion) + internal BmotionAnimationControls(IReadOnlyList elementIds, BmotionAnimationEngine engine, Task completion) { _elementIds = elementIds; _engine = engine; @@ -43,6 +41,6 @@ public void Complete() /// A that resolves when all animations finish naturally. public Task WhenCompleteAsync() => _completion; - /// Makes directly awaitable. + /// Makes directly awaitable. public TaskAwaiter GetAwaiter() => _completion.GetAwaiter(); } diff --git a/src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs similarity index 79% rename from src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs rename to src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs index 643dbef20b..917e27df9c 100644 --- a/src/Bmotion/Bit.Bmotion/Services/ScrollTracker.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -1,16 +1,13 @@ -using Bit.Bmotion.Interop; -using Bit.Bmotion.Models; using Microsoft.JSInterop; -namespace Bit.Bmotion.Services; - +namespace Bit.Bmotion; /// /// Tracks scroll progress (0–1) for a container element or the window. /// Analogous to Framer Motion's useScroll. /// /// Usage: /// -/// @inject ScrollTracker Scroll +/// @inject BmotionScrollTracker Scroll /// /// protected override async Task OnAfterRenderAsync(bool firstRender) /// { @@ -18,16 +15,16 @@ namespace Bit.Bmotion.Services; /// } /// /// -public sealed class ScrollTracker : IAsyncDisposable +public sealed class BmotionScrollTracker : IAsyncDisposable { - private readonly MotionInterop _interop; + private readonly BmotionInterop _interop; private readonly List _subscriptionKeys = new(); - private readonly DotNetObjectReference _dotnet; + private readonly DotNetObjectReference _dotnet; - private Func? _onScroll; + private Func? _onScroll; private bool _disposed; - public ScrollTracker(MotionInterop interop) + public BmotionScrollTracker(BmotionInterop interop) { _interop = interop; _dotnet = DotNetObjectReference.Create(this); @@ -47,7 +44,7 @@ public ScrollTracker(MotionInterop interop) /// /// HTML element id, or null for window. /// Callback invoked on every scroll event. - public async Task ObserveAsync(string? containerId, Func onChange) + public async Task ObserveAsync(string? containerId, Func onChange) { ObjectDisposedException.ThrowIf(_disposed, this); // Remove any existing subscription so only one stays active. @@ -61,13 +58,13 @@ public async Task ObserveAsync(string? containerId, Func onCha } /// Synchronous overload. - public Task ObserveAsync(string? containerId, Action onChange) + public Task ObserveAsync(string? containerId, Action onChange) => ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; }); // ── JS → C# callback ───────────────────────────────────────────────────── [JSInvokable] - public async Task OnScroll(ScrollInfo info) + public async Task OnScroll(BmotionScrollInfo info) { ProgressX = info.ProgressX; ProgressY = info.ProgressY; @@ -87,6 +84,6 @@ public async ValueTask DisposeAsync() _subscriptionKeys.Clear(); _onScroll = null; _dotnet?.Dispose(); - // Note: MotionInterop itself is DI-scoped and disposed by the DI container + // Note: BmotionInterop itself is DI-scoped and disposed by the DI container } } diff --git a/src/Bmotion/Bit.Bmotion/Services/MotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs similarity index 83% rename from src/Bmotion/Bit.Bmotion/Services/MotionValue.cs rename to src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs index b1fd2131a1..b1f0e57c05 100644 --- a/src/Bmotion/Bit.Bmotion/Services/MotionValue.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs @@ -1,20 +1,19 @@ -namespace Bit.Bmotion.Services; - +namespace Bit.Bmotion; /// /// A reactive numeric value whose changes can be observed and linked to animations. /// Analogous to Framer Motion's MotionValue<T>. /// Purely C# - no JS synchronisation required. /// -public class MotionValue : IDisposable where T : struct +public class BmotionValue : IDisposable where T : struct { private readonly string _id; private T _value; private readonly List> _subscribers = new(); - /// Subscription to a parent MotionValue when this instance is a derived/transformed value. + /// Subscription to a parent BmotionValue when this instance is a derived/transformed value. private IDisposable? _upstream; - internal MotionValue(string id, T initial) + internal BmotionValue(string id, T initial) { _id = id; _value = initial; @@ -69,12 +68,12 @@ public IDisposable Subscribe(Action callback) // ── Transforms ──────────────────────────────────────────────────────────── /// - /// Create a derived MotionValue that applies a transformation function. + /// Create a derived BmotionValue that applies a transformation function. /// Analogous to Framer Motion's useTransform. /// - public MotionValue Transform(Func fn) where TOut : struct + public BmotionValue Transform(Func fn) where TOut : struct { - var derived = new MotionValue($"{_id}_t", fn(_value)); + var derived = new BmotionValue($"{_id}_t", fn(_value)); // Keep the parent→derived link so it can be torn down when the derived value is disposed, // otherwise the parent would hold the derived value alive indefinitely (a leak). derived._upstream = Subscribe(async v => await derived.SetAsync(fn(v))); @@ -84,7 +83,7 @@ public MotionValue Transform(Func fn) where TOut : struct /// /// Map from an input range to an output range using linear interpolation. /// - public MotionValue Transform(double[] inputRange, double[] outputRange) + public BmotionValue Transform(double[] inputRange, double[] outputRange) { if (inputRange.Length != outputRange.Length) throw new ArgumentException("inputRange and outputRange must have the same length."); @@ -108,7 +107,7 @@ double Map(T v) return x < inputRange[0] ? outputRange[0] : outputRange[^1]; } - var derived = new MotionValue($"{_id}_tr", Map(_value)); + var derived = new BmotionValue($"{_id}_tr", Map(_value)); derived._upstream = Subscribe(async v => await derived.SetAsync(Map(v))); return derived; } @@ -127,10 +126,3 @@ private sealed class Subscription : IDisposable public void Dispose() => _dispose(); } } - -/// Factory helper for creating MotionValues. -public static class MotionValueFactory -{ - public static MotionValue Create(T initial) where T : struct - => new($"mv_{Guid.NewGuid():N}", initial); -} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs new file mode 100644 index 0000000000..125b0c31df --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs @@ -0,0 +1,8 @@ +namespace Bit.Bmotion; + +/// Factory helper for creating BmotionValues. +public static class BmotionValueFactory +{ + public static BmotionValue Create(T initial) where T : struct + => new($"mv_{Guid.NewGuid():N}", initial); +} diff --git a/src/Bmotion/Bit.Bmotion/_Imports.razor b/src/Bmotion/Bit.Bmotion/_Imports.razor index d32c668983..841dccaa9f 100644 --- a/src/Bmotion/Bit.Bmotion/_Imports.razor +++ b/src/Bmotion/Bit.Bmotion/_Imports.razor @@ -1,6 +1,2 @@ @using Microsoft.AspNetCore.Components.Web @using Bit.Bmotion -@using Bit.Bmotion.Components -@using Bit.Bmotion.Models -@using Bit.Bmotion.Services -@using Bit.Bmotion.Context diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js similarity index 99% rename from src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js rename to src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js index a0bd528f74..c4d4f6e0db 100644 --- a/src/Bmotion/Bit.Bmotion/wwwroot/BitBmotion.js +++ b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js @@ -1,5 +1,5 @@ /** - * BitBmotion.js - slim browser-API bridge + * bit-bmotion.js - slim browser-API bridge * * All animation math (spring, tween, inertia, keyframes, easing, colour * interpolation, gesture state, transform composition) now lives in the diff --git a/src/Bmotion/README.md b/src/Bmotion/README.md index 2a138e6c24..12c3a190dd 100644 --- a/src/Bmotion/README.md +++ b/src/Bmotion/README.md @@ -17,13 +17,13 @@ A Blazor-native animation library inspired by [Framer Motion](https://www.framer - [Animation Models](#animation-models) - [AnimationProps](#animationprops) - [TransitionConfig](#transitionconfig) - - [MotionVariants](#motionvariants) - - [DragOptions](#dragoptions) + - [BmotionMotionVariants](#bmotionmotionvariants) + - [BmotionDragOptions](#bmotiondragoptions) - [ViewportOptions](#viewportoptions) - [Services](#services) - - [AnimationController](#animationcontroller) - - [MotionAnimateService](#motionanimateservice) - - [MotionValue](#motionvalue) + - [BmotionAnimationController](#bmotionanimationcontroller) + - [BmotionAnimateService](#bmotionanimateservice) + - [BmotionValue](#bmotionvalue) - [Examples](#examples) - [Accessibility](#accessibility) @@ -43,7 +43,7 @@ using Bit.Bmotion; builder.Services.AddBitBmotionServices(); ``` -The browser bridge (`BitBmotion.js`) ships as a static web asset of the package and is +The browser bridge (`bit-bmotion.js`) ships as a static web asset of the package and is imported automatically the first time an animation runs, so no manual ` diff --git a/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj index 534e053f64..ff2b6cc034 100644 --- a/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj +++ b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj @@ -11,8 +11,14 @@ concrete component/service type the runtime can see, so the trimmer's IL2091 advice about propagating DynamicallyAccessedMembers annotations isn't actionable here. --> $(NoWarn);IL2091 + + README.md + + + + diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs index 3f890e59dd..603d68e993 100644 --- a/src/Bmotion/Bit.Bmotion/BitBmotion.cs +++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs @@ -5,12 +5,24 @@ namespace Bit.Bmotion; /// /// Extension methods to register Bit.Bmotion services in the DI container. /// +/// +/// +/// Platform support: Bit.Bmotion runs its animation loop over synchronous JS interop +/// and is therefore supported on Blazor WebAssembly only. It does not work on Blazor +/// Server, and it is inert during server-side prerendering (animations start once the WASM runtime +/// is interactive). Attempting to start the animation loop on a non-WebAssembly host throws +/// . +/// +/// public static class BitBmotion { /// /// Registers all Bit.Bmotion services. /// Call this in Program.cs before builder.Build(): /// builder.Services.AddBitBmotionServices(); + /// + /// Blazor WebAssembly only - see the remarks on for details. + /// /// public static IServiceCollection AddBitBmotionServices(this IServiceCollection services) { diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs index 5d27d03c6f..39511096c8 100644 --- a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -8,7 +8,7 @@ namespace Bit.Bmotion; /// Animation math runs in the C# ; JS is used only /// for DOM style mutation, pointer/focus events, viewport observation and FLIP. /// -public class Bmotion : ComponentBase, IAsyncDisposable +public sealed class Bmotion : ComponentBase, IAsyncDisposable { // ── Injected services ────────────────────────────────────────────────────── [Inject] private BmotionAnimationEngine Engine { get; set; } = null!; @@ -247,14 +247,20 @@ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTr () => OnAnimationComplete.InvokeAsync()); } } - else if (VariantCtx?.ActiveVariant is string inheritedVariant && Variants != null) + else if (VariantCtx != null && (Variants != null || VariantCtx.Variants != null)) { + // Claim a stable stagger position on first render even when no variant is active yet, + // so a parent that switches its variant later (the common hidden→visible toggle) still + // staggers its children in render order instead of collapsing every delay to zero. _variantChildIndex = VariantCtx.RegisterChild(); - _prevInheritedVariant = inheritedVariant; - var props = Variants.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant); - if (props != null) - await Engine.AnimateToAsync(_id, props.ToJsDictionary(), - BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex))); + if (VariantCtx.ActiveVariant is string inheritedVariant) + { + _prevInheritedVariant = inheritedVariant; + var props = Variants?.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant); + if (props != null) + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), + BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex))); + } } _prevAnimate = Animate; @@ -275,7 +281,7 @@ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTr } _prevAnimate = Animate; } - else if (Animate == null && Variants != null) + else if (Animate == null && (Variants != null || VariantCtx?.Variants != null)) { var newVariant = VariantCtx?.ActiveVariant; if (newVariant != _prevInheritedVariant) @@ -283,7 +289,7 @@ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTr _prevInheritedVariant = newVariant; if (newVariant != null) { - var props = Variants.Get(newVariant) ?? VariantCtx?.Variants?.Get(newVariant); + var props = Variants?.Get(newVariant) ?? VariantCtx?.Variants?.Get(newVariant); if (props != null) { double delay = _variantChildIndex >= 0 ? VariantCtx!.GetChildDelay(_variantChildIndex) : 0; @@ -524,7 +530,8 @@ public async Task OnIntersect(bool isIntersecting) private bool NeedsPathLengthAttr() => _pathDrawableTags.Contains(Tag) && - (AdditionalAttributes == null || !AdditionalAttributes.ContainsKey("pathLength")) && + (AdditionalAttributes == null || + !AdditionalAttributes.Keys.Any(k => string.Equals(k, "pathLength", StringComparison.OrdinalIgnoreCase))) && (HasPathLength(Initial) || HasPathLength(Animate) || HasPathLength(Exit) || HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) || HasPathLength(WhileInView) || HasPathLength(WhileDrag)); @@ -575,7 +582,13 @@ private static BmotionTransitionConfig InstantTransition() if (ConfigCtx?.TransitionSpeed is double speed && speed != 1.0) { t = t.Clone(); + // Scale every time-based field so the whole animation is consistently sped up / + // slowed down - not just the tween duration (which left delays and duration-based + // springs out of sync with the requested speed). t.Duration *= speed; + t.Delay *= speed; + t.RepeatDelay *= speed; + if (t.VisualDuration.HasValue) t.VisualDuration *= speed; } return t; } diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs index 5900852bb5..6c7a773120 100644 --- a/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs @@ -63,9 +63,23 @@ protected override void OnParametersSet() { if (_prevIsPresent && !IsPresent) { - // Children are leaving - signal exiting state so Bmotion components play Exit - _presenceCtx.IsExiting = true; - _shouldRender = true; // keep rendering until exit completes + // A fresh leave invalidates any pending deferred enter; clear it so stale + // deferred-enter state can't remount the children after this exit completes. + _deferEnter = false; + + if (_presenceCtx.ChildCount > 0) + { + // Children are leaving - signal exiting state so Bmotion components play Exit + _presenceCtx.IsExiting = true; + _shouldRender = true; // keep rendering until exit completes + } + else + { + // No animatable children registered: AllExitsComplete would never fire, so + // flagging IsExiting/keeping _shouldRender true would strand the content. Drop it now. + _presenceCtx.IsExiting = false; + _shouldRender = false; + } } else if (!_prevIsPresent && IsPresent) { diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs index cb01110521..fd3e141e12 100644 --- a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs @@ -23,9 +23,9 @@ public double TransitionSpeed get => _transitionSpeed; set { - if (value < 0) + if (!double.IsFinite(value) || value < 0) throw new ArgumentOutOfRangeException(nameof(value), value, - "TransitionSpeed must be non-negative."); + "TransitionSpeed must be a finite, non-negative number."); _transitionSpeed = value; } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index 56594bacb5..48986f03bf 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -18,6 +18,10 @@ public sealed class BmotionAnimationEngine : IAsyncDisposable private bool _loopRunning; private bool _reducedMotionDetected; + // Reused across frames so the rAF tick doesn't allocate a fresh outer dictionary every ~16 ms. + // Marshaled synchronously to JS before the next ComputeFrame runs (single-threaded Blazor WASM). + private readonly Dictionary> _frameResult = new(); + public BmotionAnimationEngine(BmotionInterop interop) => _interop = interop; // ═══════════════════════════════════════════════════════════════════════════ @@ -62,15 +66,22 @@ public void RegisterElement(string elementId, Dictionary? initi state = new BmotionElementAnimationState(); _elements[elementId] = state; } + // Reference-counted: the same element may be owned by a and one or more + // concurrent AnimateAsync calls at once. + state.RefCount++; if (initialValues != null) state.SetInstant(initialValues); } - /// Remove an element and cancel all its animations. + /// Release one owner; cancels animations and removes the element only when the last owner releases it. public void UnregisterElement(string elementId) { if (_elements.TryGetValue(elementId, out var state)) { + if (state.RefCount > 0) state.RefCount--; + // Other owners (e.g. an overlapping animation) still hold the element - keep it alive + // so their in-flight animations aren't stranded by a premature teardown. + if (state.RefCount > 0) return; state.CancelAll(); _elements.Remove(elementId); } @@ -91,12 +102,17 @@ public async ValueTask AnimateToAsync( state.SetBaseAnimation(values, transition); if (onComplete != null) { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.AnimateTo(values, transition, tcs); await EnsureLoopRunningAsync(); // .Unwrap() so the nested onComplete() Task is observed rather than dropped // (keeps the documented fire-and-forget behaviour of this method). - _ = tcs.Task.ContinueWith(_ => onComplete(), TaskScheduler.Default).Unwrap(); + // The result flag is true only on natural completion; a superseded/cancelled + // animation resolves with false so OnAnimationComplete is NOT raised for it. + _ = tcs.Task.ContinueWith( + t => t.Result ? onComplete() : Task.CompletedTask, + TaskScheduler.Default) + .Unwrap(); } else { @@ -112,7 +128,7 @@ public async ValueTask AnimateToAwaitAsync( BmotionTransitionConfig? transition) { if (!_elements.TryGetValue(elementId, out var state)) return; - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.SetBaseAnimation(values, transition); state.AnimateTo(values, transition, tcs); await EnsureLoopRunningAsync(); @@ -306,18 +322,44 @@ public async ValueTask EndDragAsync( [JSInvokable] public Dictionary>? ComputeFrame(double timestamp) { + // Clear the reused outer buffer so entries from the previous frame don't leak through. + _frameResult.Clear(); Dictionary>? result = null; bool anyActive = false; + List? faulted = null; foreach (var (id, state) in _elements) { - var updates = state.Tick(timestamp); - if (updates is { Count: > 0 }) + try + { + var updates = state.Tick(timestamp); + if (updates is { Count: > 0 }) + { + result ??= _frameResult; + result[id] = updates; + } + if (state.HasActiveAnimations) anyActive = true; + } + catch + { + // A single malformed value or driver fault must not take down the whole loop + // (a thrown exception would propagate into the synchronous JS rAF tick and + // permanently stop animation for every element). Isolate and evict the bad + // element instead, then keep ticking the rest. + (faulted ??= new List()).Add(id); + } + } + + if (faulted != null) + { + foreach (var id in faulted) { - result ??= new Dictionary>(); - result[id] = updates; + if (_elements.TryGetValue(id, out var badState)) + { + try { badState.CancelAll(); } catch { /* best-effort cleanup */ } + _elements.Remove(id); + } } - if (state.HasActiveAnimations) anyActive = true; } if (!anyActive) @@ -353,7 +395,9 @@ private void StopLoopInternal() { if (!_loopRunning) return; _loopRunning = false; - _ = _interop.StopRafLoopAsync(); + // Pass our own engine ref so only this engine is removed from the shared JS loop, + // leaving any other Blazor-root engines ticking. + _ = _interop.StopRafLoopAsync(_dotnet); } public async ValueTask DisposeAsync() @@ -361,10 +405,18 @@ public async ValueTask DisposeAsync() foreach (var (_, state) in _elements) state.CancelAll(); _elements.Clear(); - StopLoopInternal(); - _dotnet?.Dispose(); + + // Await the loop stop before disposing _dotnet so the JS call doesn't marshal a + // disposed DotNetObjectReference. StopLoopInternal's fire-and-forget path is fine for + // mid-session stops, but during teardown we must order it explicitly. + _loopRunning = false; + if (_dotnet != null) + { + try { await _interop.StopRafLoopAsync(_dotnet); } catch { /* ignore during teardown */ } + _dotnet.Dispose(); + _dotnet = null; + } // BmotionInterop is owned and disposed by the DI container (it is registered scoped), // so the engine must not dispose it here or it would be disposed twice. - await ValueTask.CompletedTask; } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs index fa8c758591..5ff7f8cb04 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs @@ -3,19 +3,47 @@ namespace Bit.Bmotion; /// Pure-C# RGBA color parsing and linear interpolation. /// Handles #hex, rgb(), rgba(), hsl(), and hsla() formats. /// -internal static class BmotionColorInterpolator +internal static partial class BmotionColorInterpolator { - /// Linearly interpolates between two CSS color strings at progress (0–1). + // Source-generated regexes: faster than the static Regex cache and trim-safe (no runtime + // IL emit), which matters because this assembly is built with IsTrimmable=true. + [System.Text.RegularExpressions.GeneratedRegex( + @"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex RgbRegex(); + + [System.Text.RegularExpressions.GeneratedRegex( + @"hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex HslRegex(); + /// + /// Linearly interpolates between two CSS color strings at progress (0–1). + /// + /// Interpolation is performed per-channel in the sRGB color space (matching Framer Motion's + /// default). This is fast and predictable, but mixing complementary colors can pass through a + /// desaturated mid-point (e.g. blue→yellow briefly looks grey). A perceptual space such as + /// OKLab would avoid that at extra cost; sRGB is intentional here for parity and performance. + /// + /// public static string Lerp(string from, string to, double t) { var f = Parse(from); var tt = Parse(to); if (f == null || tt == null) return to; + return Lerp(f, tt, t); + } - int r = (int)Math.Round(f[0] + (tt[0] - f[0]) * t); - int g = (int)Math.Round(f[1] + (tt[1] - f[1]) * t); - int b = (int)Math.Round(f[2] + (tt[2] - f[2]) * t); - double a = f[3] + (tt[3] - f[3]) * t; + /// + /// Interpolates between two pre-parsed RGBA channel arrays (as produced by ). + /// Drivers parse their colors once up-front and call this each tick, avoiding the per-frame + /// regex/parse cost of the string overload. + /// + public static string Lerp(double[] from, double[] to, double t) + { + int r = (int)Math.Round(from[0] + (to[0] - from[0]) * t); + int g = (int)Math.Round(from[1] + (to[1] - from[1]) * t); + int b = (int)Math.Round(from[2] + (to[2] - from[2]) * t); + double a = from[3] + (to[3] - from[3]) * t; return $"rgba({r},{g},{b},{BmotionCssFormat.Num(a, "G4")})"; } @@ -28,7 +56,12 @@ public static bool LooksLikeColor(string? value) // ── Internal ────────────────────────────────────────────────────────────── - private static double[]? Parse(string c) + /// + /// Parses a CSS color into its [r, g, b, a] channels (0–255 for RGB, 0–1 for alpha), + /// or returns null when the string is not a recognised color. Exposed to drivers so + /// they can parse keyframes once instead of on every tick. + /// + internal static double[]? Parse(string c) { if (string.IsNullOrEmpty(c)) return null; @@ -53,8 +86,7 @@ public static bool LooksLikeColor(string? value) } // rgb() / rgba() - var m = System.Text.RegularExpressions.Regex.Match( - c, @"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)"); + var m = RgbRegex().Match(c); if (m.Success) { return @@ -67,8 +99,7 @@ public static bool LooksLikeColor(string? value) } // hsl() / hsla() - var mh = System.Text.RegularExpressions.Regex.Match( - c, @"hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)"); + var mh = HslRegex().Match(c); if (mh.Success) { double h2 = BmotionCssFormat.Parse(mh.Groups[1].Value); diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs index 31d75722d3..a036def56c 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs @@ -17,6 +17,7 @@ internal sealed class BmotionColorKeyframesDriver : IBmotionAnimationDriver private bool _cancelled; private int _iteration; private string[] _curFrames; + private readonly double[]?[] _curChannels; public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig config, Action apply) { @@ -25,7 +26,7 @@ public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig conf if (config.Times != null && config.Times.Length != frames.Length) throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); - _frames = frames; + _frames = (string[])frames.Clone(); _curFrames = (string[])frames.Clone(); _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; @@ -42,11 +43,19 @@ public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig conf : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); var globalEase = BmotionEasingFunctions.Get(config); _eases = Enumerable.Repeat(globalEase, n - 1).ToArray(); + + // Parse each frame's color once up-front; Tick() then only interpolates pre-parsed + // channels instead of running the color regex on every frame (~60 fps). + _curChannels = new double[]?[n]; + for (int i = 0; i < n; i++) + _curChannels[i] = BmotionColorInterpolator.Parse(_curFrames[i]); } public bool Tick(double timestamp) { - if (_cancelled) { _apply(_frames[^1]); return true; } + // Freeze at the current value on cancel (consistent with the other drivers); callers + // remove the driver immediately after Cancel(), so this branch is defensive only. + if (_cancelled) return true; if (_startTime < 0) _startTime = timestamp + _delayMs; if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } @@ -59,7 +68,13 @@ public bool Tick(double timestamp) double segLen = _times[seg + 1] - _times[seg]; double segT = segLen > 0 ? (t - _times[seg]) / segLen : 1.0; double easedT = _eases[seg](Math.Min(segT, 1.0)); - _apply(BmotionColorInterpolator.Lerp(_curFrames[seg], _curFrames[seg + 1], easedT)); + var ca = _curChannels[seg]; + var cb = _curChannels[seg + 1]; + // Fall back to the raw target frame string when a color couldn't be parsed + // (matches the string Lerp returning 'to' for unparseable input). + _apply(ca != null && cb != null + ? BmotionColorInterpolator.Lerp(ca, cb, easedT) + : _curFrames[seg + 1]); if (t >= 1.0) { @@ -70,6 +85,7 @@ public bool Tick(double timestamp) if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) { Array.Reverse(_curFrames); + Array.Reverse(_curChannels); MirrorTimes(_times); } return false; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs index 3fa92c4ce6..3eb34f4017 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs @@ -18,11 +18,16 @@ internal sealed class BmotionColorTweenDriver : IBmotionAnimationDriver private int _iteration; private string _curFrom; private string _curTo; + private double[]? _curFromCh; + private double[]? _curToCh; public BmotionColorTweenDriver(string from, string to, BmotionTransitionConfig config, Action apply) { _curFrom = from; _curTo = _to = to; + // Parse once up-front so Tick() doesn't run the color regex ~60 times per second. + _curFromCh = BmotionColorInterpolator.Parse(from); + _curToCh = BmotionColorInterpolator.Parse(to); _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; _easeFn = BmotionEasingFunctions.Get(config); @@ -43,7 +48,11 @@ public bool Tick(double timestamp) double elapsed = timestamp - _startTime; double t = _durationMs > 0 ? Math.Min(elapsed / _durationMs, 1.0) : 1.0; double p = _easeFn(t); - _apply(BmotionColorInterpolator.Lerp(_curFrom, _curTo, p)); + // Fall back to the raw target string when a color couldn't be parsed (matches the + // string Lerp's behaviour of returning 'to' for unparseable input). + _apply(_curFromCh != null && _curToCh != null + ? BmotionColorInterpolator.Lerp(_curFromCh, _curToCh, p) + : _curTo); if (t >= 1.0) { @@ -52,7 +61,10 @@ public bool Tick(double timestamp) if (!_isInfinite) _iteration++; _startTime = timestamp + _repeatDelayMs; if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) + { (_curFrom, _curTo) = (_curTo, _curFrom); + (_curFromCh, _curToCh) = (_curToCh, _curFromCh); + } return false; } return true; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs index be67560b0f..fa5a85df12 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -19,6 +19,12 @@ internal sealed class BmotionElementAnimationState /// Current values of color / string properties (backgroundColor, color, …). internal readonly Dictionary StringValues = new(); + /// + /// Number of live owners holding this element (a wrapping <Bmotion>, controllers, and + /// in-flight AnimateAsync calls). The engine only tears the element down when this hits zero. + /// + internal int RefCount; + // ── Active animations ───────────────────────────────────────────────────── private readonly Dictionary _activeAnims = new(); @@ -29,7 +35,10 @@ internal sealed class BmotionElementAnimationState private BmotionTransitionConfig? _baseTransition; // ── Animation completion tracking ───────────────────────────────────────── - private TaskCompletionSource? _completionSource; + // Result flag: true = animation finished naturally (or was snapped to its end via + // CompleteAll); false = it was superseded by a new animation or cancelled. Callers use the + // flag to avoid raising "complete" callbacks for interrupted animations. + private TaskCompletionSource? _completionSource; // ── Drag state ──────────────────────────────────────────────────────────── private bool _isDragging; @@ -38,6 +47,11 @@ internal sealed class BmotionElementAnimationState private bool _transformDirty; private readonly HashSet _dirtyProps = new(); + // Reused across frames to avoid allocating a fresh CSS-update dictionary every rAF tick. + // Safe because the synchronous JS interop marshals the returned dictionary before the next + // Tick runs (single-threaded Blazor WASM), so the buffer is never read after it is cleared. + private readonly Dictionary _updateBuffer = new(); + public bool HasActiveAnimations => _activeAnims.Count > 0 || _isDragging; // ═══════════════════════════════════════════════════════════════════════════ @@ -68,14 +82,15 @@ internal sealed class BmotionElementAnimationState // Signal awaiter if all finished if (_completionSource != null && _activeAnims.Count == 0) { - _completionSource.TrySetResult(); + _completionSource.TrySetResult(true); // natural completion _completionSource = null; } if (!_transformDirty && _dirtyProps.Count == 0) return null; - // ── Build CSS style update dict ──────────────────────────────────────── - var updates = new Dictionary(_dirtyProps.Count + 1); + // ── Build CSS style update dict (reused buffer) ──────────────────────── + var updates = _updateBuffer; + updates.Clear(); if (_transformDirty) updates["transform"] = BmotionTransformComposer.Build(Transforms); @@ -129,18 +144,19 @@ internal sealed class BmotionElementAnimationState public void AnimateTo( Dictionary values, BmotionTransitionConfig? transition, - TaskCompletionSource? completionSource = null) + TaskCompletionSource? completionSource = null) { // Cheap scan for any non-null target (no allocation). bool any = false; foreach (var v in values.Values) if (v != null) { any = true; break; } - if (!any) { completionSource?.TrySetResult(); return; } + if (!any) { completionSource?.TrySetResult(true); return; } // Complete any previously-pending awaiter so callers aren't stranded when a - // new animation supersedes the old one. + // new animation supersedes the old one. The old one resolves with false so its + // completion callback (OnAnimationComplete) is suppressed - it was interrupted. if (_completionSource != null && !ReferenceEquals(_completionSource, completionSource)) - _completionSource.TrySetResult(); + _completionSource.TrySetResult(false); _completionSource = completionSource; foreach (var (key, value) in values) @@ -150,9 +166,23 @@ public void AnimateTo( CancelProp(key); if (TryGetDoubleArray(value, out double[]? doubleFrames)) - CreateNumericKeyframesDriver(key, doubleFrames!, perKey); + { + // Keyframe drivers require at least two frames (they build n-1 segments and + // divide by n-1 when distributing times). Degenerate arrays would otherwise + // throw and (via ComputeFrame) stall the whole loop, so handle them here: + // 0 frames -> nothing to do; 1 frame -> snap to that single value. + if (doubleFrames!.Length >= 2) + CreateNumericKeyframesDriver(key, doubleFrames, perKey); + else if (doubleFrames.Length == 1) + CreateNumericDriver(key, doubleFrames[0], perKey); + } else if (IsColorProp(key) && TryGetStringArray(value, out string[]? strFrames)) - CreateColorKeyframesDriver(key, strFrames!, perKey); + { + if (strFrames!.Length >= 2) + CreateColorKeyframesDriver(key, strFrames, perKey); + else if (strFrames.Length == 1) + CreateColorDriver(key, strFrames[0], perKey); + } else if (IsColorProp(key) && value is string colorStr) CreateColorDriver(key, colorStr, perKey); else if (value is string dimStr) @@ -212,7 +242,7 @@ public void Cancel(string[]? properties) // of AnimateToAwaitAsync don't hang forever (matches CancelAll's behaviour). if (_activeAnims.Count == 0 && _completionSource != null) { - _completionSource.TrySetResult(); + _completionSource.TrySetResult(false); // cancelled, not completed _completionSource = null; } } @@ -223,7 +253,7 @@ internal void CancelAll() foreach (var driver in _activeAnims.Values) driver.Cancel(); _activeAnims.Clear(); - _completionSource?.TrySetResult(); + _completionSource?.TrySetResult(false); // cancelled, not completed _completionSource = null; } @@ -237,7 +267,7 @@ internal void CompleteAll() foreach (var driver in _activeAnims.Values) driver.Complete(); _activeAnims.Clear(); - _completionSource?.TrySetResult(); + _completionSource?.TrySetResult(true); // snapped to end values = completed _completionSource = null; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index 17bf4dbf5b..1f339fc4d7 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -50,7 +50,9 @@ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig co public bool Tick(double timestamp) { - if (_cancelled) { _apply(_frames[^1]); return true; } + // Freeze at the current value on cancel (consistent with the other drivers); callers + // remove the driver immediately after Cancel(), so this branch is defensive only. + if (_cancelled) return true; if (_startTime < 0) _startTime = timestamp + _delayMs; if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs index fc26218cd6..b32bf712f9 100644 --- a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs @@ -36,11 +36,11 @@ public BmotionInterop(IJSRuntime js) public async ValueTask StartRafLoopAsync(DotNetObjectReference dotnetRef) where T : class => await (await Module()).InvokeVoidAsync("startRafLoop", dotnetRef); - /// Stop the JS rAF loop. - public async ValueTask StopRafLoopAsync() + /// Stop the JS rAF loop for the given engine reference (or all engines when null). + public async ValueTask StopRafLoopAsync(DotNetObjectReference? dotnetRef = null) where T : class { if (!_moduleTask.IsValueCreated) return; - await (await Module()).InvokeVoidAsync("stopRafLoop"); + await (await Module()).InvokeVoidAsync("stopRafLoop", dotnetRef); } // ── Reduced motion (accessibility) ──────────────────────────────────────── diff --git a/src/Bmotion/Bit.Bmotion/README.md b/src/Bmotion/Bit.Bmotion/README.md new file mode 100644 index 0000000000..87fc118777 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/README.md @@ -0,0 +1,32 @@ +# Bit.Bmotion + +A Framer-Motion-style animation library for Blazor. All animation math (spring, tween, +inertia, keyframes, easing, color interpolation, gesture state, transform composition) runs +in C#; JavaScript is used only as a thin bridge to browser-native APIs. + +## ⚠️ Platform support: Blazor WebAssembly only + +Bit.Bmotion drives its animation loop over **synchronous** JS↔.NET interop. That is only +available on **Blazor WebAssembly**, so: + +- ✅ **Blazor WebAssembly** — fully supported. +- ❌ **Blazor Server** — not supported (synchronous interop is unavailable). Starting the + animation loop throws `PlatformNotSupportedException`. +- ⏸️ **Server-side prerendering** — components render their initial styles, but animations + do not start until the WebAssembly runtime becomes interactive. + +## Getting started + +```csharp +// Program.cs (WebAssembly host) +builder.Services.AddBitBmotionServices(); +``` + +```razor + +``` + +See the XML documentation on `Bmotion`, `BmotionAnimateService`, and `BmotionTransitionConfig` +for the full API surface. diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs index 5e559e2450..1672ac4a00 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs @@ -91,17 +91,12 @@ private BmotionAnimationControls StartAnimations( { var values = keyframes.ToJsDictionary(); - // Only the elements we register here (i.e. not already owned by a ) are ours to - // clean up afterwards, so the engine's element table doesn't grow unbounded over time. - var ours = new List(); + // Reference-count every target for the lifetime of this call. Overlapping AnimateAsync + // invocations (and any wrapping ) each hold a count, so an element is only torn + // down once the last animation settles - a single completing call can't unregister an + // element out from under another still-running animation. foreach (var id in elementIds) - { - if (!_engine.IsRegistered(id)) - { - _engine.RegisterElement(id); - ours.Add(id); - } - } + _engine.RegisterElement(id); // Start all animations concurrently; collect their completion tasks. var completionTasks = elementIds @@ -110,15 +105,12 @@ private BmotionAnimationControls StartAnimations( var completion = Task.WhenAll(completionTasks); - if (ours.Count > 0) + // Release our refcount on every target once the animations settle. + _ = completion.ContinueWith(_ => { - // Release engine state for the elements we created once their animations settle. - _ = completion.ContinueWith(_ => - { - foreach (var id in ours) - _engine.UnregisterElement(id); - }, TaskScheduler.Default); - } + foreach (var id in elementIds) + _engine.UnregisterElement(id); + }, TaskScheduler.Default); return new BmotionAnimationControls(elementIds, _engine, completion); } diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs index 6c855f3d80..24d7cd2853 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs @@ -21,6 +21,8 @@ public void BindTo(string elementId) { if (string.IsNullOrWhiteSpace(elementId)) throw new ArgumentException("Element ID must not be null or whitespace.", nameof(elementId)); + // Already bound to this element: avoid re-registering (which would unbalance the engine refcount). + if (_elementId == elementId) return; // Release the previously bound element so repeated BindTo calls don't leak engine state. if (!string.IsNullOrEmpty(_elementId) && _elementId != elementId) _engine.UnregisterElement(_elementId); @@ -31,21 +33,21 @@ public void BindTo(string elementId) /// Animate the bound element to the given props (fire-and-forget). public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { - if (_elementId == null) return; + if (_elementId == null || props == null) return; await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition); } /// Animate and await completion. public async ValueTask AnimateAwaitAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { - if (_elementId == null) return; + if (_elementId == null || props == null) return; await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition); } /// Instantly set props without animation. public void Set(BmotionAnimationProps props) { - if (_elementId == null) return; + if (_elementId == null || props == null) return; _engine.SetInstant(_elementId, props.ToJsDictionary()); } diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs index dd868e46cb..8cdaf725b3 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -60,7 +60,10 @@ public async Task ObserveAsync(string? containerId, FuncSynchronous overload. public Task ObserveAsync(string? containerId, Action onChange) - => ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; }); + { + ArgumentNullException.ThrowIfNull(onChange); + return ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; }); + } // ── JS → C# callback ───────────────────────────────────────────────────── diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js index fb736d9c80..d3676582f8 100644 --- a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js +++ b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js @@ -20,35 +20,43 @@ // let _rafId = null; -let _animEngine = null; +// Set of engine DotNetObjectReferences. Using a set (rather than a single global) means +// multiple Blazor roots / engine instances sharing this module each get ticked, instead of a +// second startRafLoop silently hijacking the loop from the first. +const _engines = new Set(); export function startRafLoop(dotnetRef) { - _animEngine = dotnetRef; - if (_rafId !== null) cancelAnimationFrame(_rafId); - _rafId = requestAnimationFrame(_tick); + _engines.add(dotnetRef); + if (_rafId === null) _rafId = requestAnimationFrame(_tick); } -export function stopRafLoop() { - if (_rafId !== null) { cancelAnimationFrame(_rafId); _rafId = null; } - _animEngine = null; +export function stopRafLoop(dotnetRef) { + // With an argument, stop only that engine; without one, stop everything (back-compat). + if (dotnetRef) _engines.delete(dotnetRef); + else _engines.clear(); + if (_engines.size === 0 && _rafId !== null) { + cancelAnimationFrame(_rafId); + _rafId = null; + } } function _tick(timestamp) { - if (!_animEngine) return; - try { - // invokeMethod is synchronous in Blazor WASM C# does all animation math here - const updates = _animEngine.invokeMethod('ComputeFrame', timestamp); - if (updates) { - for (const elementId in updates) { - const el = document.getElementById(elementId); - if (!el) continue; - _applyStyles(el, updates[elementId]); + if (_engines.size === 0) { _rafId = null; return; } + for (const ref of _engines) { + try { + // invokeMethod is synchronous in Blazor WASM C# does all animation math here + const updates = ref.invokeMethod('ComputeFrame', timestamp); + if (updates) { + for (const elementId in updates) { + const el = document.getElementById(elementId); + if (!el) continue; + _applyStyles(el, updates[elementId]); + } } + } catch (e) { + // Never let a fault from one engine's synchronous tick stop the shared rAF loop. + console.error('bmotion: ComputeFrame tick failed', e); } - } catch (e) { - // Swallow transient frame failures so the rAF loop always reschedules below and - // animations don't stall permanently after a single bad frame. - console.error('bmotion: ComputeFrame tick failed', e); } _rafId = requestAnimationFrame(_tick); } @@ -194,7 +202,9 @@ export function attachEventListeners(elementId, events, dotnetRef) { // Pan (detects movement ≥ 3px without moving the element) if (events.pan) { - _attachPan(el, dotnetRef, cleanups); + // When drag is also active it already calls setPointerCapture; let pan reuse that capture + // instead of grabbing the pointer a second time for the same element. + _attachPan(el, dotnetRef, cleanups, !!events.drag); } // Drag @@ -203,7 +213,7 @@ export function attachEventListeners(elementId, events, dotnetRef) { } } -function _attachPan(el, dotnetRef, cleanups) { +function _attachPan(el, dotnetRef, cleanups, skipCapture) { const PAN_THRESHOLD = 3; // pixels before pan is detected let down = false; // whether a pointer is currently pressed on this element let panning = false; @@ -215,7 +225,8 @@ function _attachPan(el, dotnetRef, cleanups) { down = true; startX = lastX = e.clientX; startY = lastY = e.clientY; lastT = Date.now(); velX = velY = 0; panning = false; - el.setPointerCapture(e.pointerId); + // Skip when drag already owns the pointer capture for this element. + if (!skipCapture) el.setPointerCapture(e.pointerId); }; const onMove = (e) => { diff --git a/src/Bmotion/README.md b/src/Bmotion/README.md index a9da544baf..784335adfe 100644 --- a/src/Bmotion/README.md +++ b/src/Bmotion/README.md @@ -1,4 +1,4 @@ -# bit Bmotion +# Bit.Bmotion A Blazor-native animation library inspired by [Framer Motion](https://www.framer.com/motion/). Springs, gestures, layout animations, variants, and keyframes - **no manual JavaScript wiring required**. All animation math runs in C# via WebAssembly; the slim browser bridge is auto-loaded for you. diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorTweenDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorTweenDriverTests.cs index 06a559b709..5bc8d18df9 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorTweenDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/ColorTweenDriverTests.cs @@ -87,7 +87,7 @@ public void Tick_DuringDelay_AppliesFromColor() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToRawTargetString() + public void Cancel_CompletesImmediately() { string? lastValue = null; var driver = new BmotionColorTweenDriver( @@ -99,8 +99,8 @@ public void Cancel_SnapsToRawTargetString() driver.Cancel(); bool done = driver.Tick(100); - // Cancel snaps to the original 'to' string (not the interpolated form) - Assert.AreEqual("#ffffff", lastValue); + // Cancel() freezes in place rather than snapping to the target string; + // only completion is guaranteed. Assert.IsTrue(done); } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/InertiaDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/InertiaDriverTests.cs index e3ff2dc1f9..1e0deee321 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/InertiaDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/InertiaDriverTests.cs @@ -132,7 +132,7 @@ public void Tick_DuringDelay_HoldsAtStart() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToProjectedTarget() + public void Cancel_CompletesImmediately() { double lastValue = 0; var config = new BmotionTransitionConfig @@ -144,13 +144,12 @@ public void Cancel_SnapsToProjectedTarget() }; var driver = new BmotionInertiaDriver(0, config, v => lastValue = v); - double projected = 0 + 0.8 * 1000; // 800 - driver.Tick(0); driver.Cancel(); bool done = driver.Tick(16); - Assert.AreEqual(projected, lastValue, 1e-5); + // Cancel() freezes in place rather than snapping to the projected target; + // only completion is guaranteed. Assert.IsTrue(done); } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/KeyframesDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/KeyframesDriverTests.cs index ae8c231330..e6cfc574a0 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/KeyframesDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/KeyframesDriverTests.cs @@ -85,7 +85,7 @@ public void Tick_CustomTimes_RespectsKeyframePlacement() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToLastFrame() + public void Cancel_CompletesImmediately() { var log = new List(); var driver = new BmotionNumericKeyframesDriver( @@ -97,7 +97,8 @@ public void Cancel_SnapsToLastFrame() driver.Cancel(); bool done = driver.Tick(100); - Assert.AreEqual(100.0, log[^1], 1e-5); + // Cancel() freezes in place rather than snapping to the last frame; + // only completion is guaranteed. Assert.IsTrue(done); } @@ -197,7 +198,7 @@ public void Tick_AtEnd_ReturnsTrue() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToOriginalLastFrame() + public void Cancel_CompletesImmediately() { string? lastValue = null; var driver = new BmotionColorKeyframesDriver( @@ -207,9 +208,11 @@ public void Cancel_SnapsToOriginalLastFrame() driver.Tick(0); driver.Cancel(); - driver.Tick(50); + bool done = driver.Tick(50); - Assert.AreEqual("#ff0000", lastValue); + // Cancel() freezes in place rather than snapping to the last frame; + // only completion is guaranteed. + Assert.IsTrue(done); } // ── Mirror repeat ───────────────────────────────────────────────────────── diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/SpringDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/SpringDriverTests.cs index d54a43eefa..74fcf562f7 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/SpringDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/SpringDriverTests.cs @@ -111,7 +111,7 @@ public void Tick_DuringDelay_HoldsAtFromValue() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToTarget() + public void Cancel_CompletesImmediately() { double lastValue = 0; var driver = new BmotionSpringDriver(0, 100, new BmotionTransitionConfig @@ -126,7 +126,8 @@ public void Cancel_SnapsToTarget() driver.Cancel(); bool done = driver.Tick(32); - Assert.AreEqual(100.0, lastValue, 1e-5); + // Cancel() freezes the animation in place; it does not snap to the target. Only + // completion is guaranteed (Complete() is the operation that writes the end value). Assert.IsTrue(done); } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs index 6a9ff59c98..d8811b8ee5 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs @@ -102,7 +102,7 @@ public void Tick_AfterDelay_CompletesAtExpectedTime() // ── Cancel ──────────────────────────────────────────────────────────────── [TestMethod] - public void Cancel_SnapsToTarget() + public void Cancel_CompletesImmediately() { var log = new List(); var driver = Create(0, 100, new BmotionTransitionConfig { Duration = 0.3 }, log); @@ -111,7 +111,8 @@ public void Cancel_SnapsToTarget() driver.Cancel(); bool done = driver.Tick(150); - Assert.AreEqual(100.0, log[^1], 1e-5); + // Cancel() freezes the animation in place rather than snapping to the target; + // only completion is guaranteed. Assert.IsTrue(done); } From fc5046f132186cd7dcb870786fb54e5e46c6dbe8 Mon Sep 17 00:00:00 2001 From: msynk Date: Mon, 15 Jun 2026 19:55:06 +0330 Subject: [PATCH 10/24] resolve local review findings II --- src/Bmotion/Bit.Bmotion/Components/Bmotion.cs | 61 +++++- .../Components/BmotionConfig.razor | 5 +- .../Context/BmotionConfigContext.cs | 14 +- .../Context/BmotionVariantContext.cs | 7 + .../Engine/BmotionAnimationEngine.cs | 47 +++- .../Engine/BmotionColorKeyframesDriver.cs | 2 +- .../Engine/BmotionColorTweenDriver.cs | 2 +- .../Engine/BmotionElementAnimationState.cs | 203 ++++++++++++++---- .../Engine/BmotionNumericKeyframesDriver.cs | 2 +- .../Bit.Bmotion/Engine/BmotionSpringDriver.cs | 2 +- .../Bit.Bmotion/Engine/BmotionTweenDriver.cs | 2 +- .../Bit.Bmotion/Interop/BmotionInterop.cs | 14 ++ src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs | 17 ++ .../Models/BmotionTransitionConfig.cs | 15 +- .../Services/BmotionAnimateService.cs | 19 +- .../Services/BmotionAnimationController.cs | 7 + .../Services/BmotionAnimationControls.cs | 26 ++- .../Services/BmotionScrollTracker.cs | 22 +- .../Bit.Bmotion/wwwroot/bit-bmotion.js | 84 ++++++-- 19 files changed, 464 insertions(+), 87 deletions(-) create mode 100644 src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs index 39511096c8..27fd95538e 100644 --- a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -94,6 +94,11 @@ public sealed class Bmotion : ComponentBase, IAsyncDisposable private string? _prevInheritedVariant; private int _variantChildIndex = -1; private BmotionBoundingRect? _layoutSnapshot; + // The style string most recently emitted into the render tree, and the one we've reconciled + // with the engine. When these diverge after init, Blazor has rewritten the element's inline + // style, so we re-flush the engine's live values on top to avoid resetting animated props. + private string _pendingStyle = string.Empty; + private string _committedStyle = string.Empty; // ════════════════════════════════════════════════════════════════════════════ // Rendering @@ -120,6 +125,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) var motionStyle = BuildInitialStyle(); var combinedStyle = string.IsNullOrEmpty(Style) ? motionStyle : motionStyle + Style; + _pendingStyle = combinedStyle ?? string.Empty; if (!string.IsNullOrEmpty(combinedStyle)) builder.AddAttribute(5, "style", combinedStyle); @@ -127,16 +133,38 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) if (Variants != null) { - _ownVariantCtx ??= new BmotionVariantContext(); // Fall back to an inherited active variant from an ancestor so nested variant trees // propagate the active label when this component doesn't set its own Animate.Variant. - _ownVariantCtx.ActiveVariant = Animate?.IsVariant == true ? Animate.Variant : VariantCtx?.ActiveVariant; + var active = Animate?.IsVariant == true ? Animate.Variant : VariantCtx?.ActiveVariant; // Mirror the ActiveVariant fallback: descendants inherit the initial variant label from // an ancestor when this node defines Variants without its own local Initial variant. - _ownVariantCtx.InitialVariant = Initial?.IsVariant == true ? Initial.Variant : VariantCtx?.InitialVariant; - _ownVariantCtx.Variants = Variants; - _ownVariantCtx.StaggerChildren = Transition?.StaggerChildren ?? 0; - _ownVariantCtx.DelayChildren = Transition?.DelayChildren ?? 0; + var initial = Initial?.IsVariant == true ? Initial.Variant : VariantCtx?.InitialVariant; + var stagger = Transition?.StaggerChildren ?? 0; + var delayChildren = Transition?.DelayChildren ?? 0; + + // A cascaded context whose fields are mutated in place does NOT reliably notify + // descendants (CascadingValue change-detection is reference-based). So when any cascaded + // value actually changes, publish a NEW context instance - the changed reference forces + // CascadingValue to re-notify children. The child-index counter is carried over so any + // child registering after the swap still gets a stable stagger position. + if (_ownVariantCtx is null || + _ownVariantCtx.ActiveVariant != active || + _ownVariantCtx.InitialVariant != initial || + !ReferenceEquals(_ownVariantCtx.Variants, Variants) || + _ownVariantCtx.StaggerChildren != stagger || + _ownVariantCtx.DelayChildren != delayChildren) + { + var previous = _ownVariantCtx; + _ownVariantCtx = new BmotionVariantContext + { + ActiveVariant = active, + InitialVariant = initial, + Variants = Variants, + StaggerChildren = stagger, + DelayChildren = delayChildren, + }; + if (previous != null) _ownVariantCtx.SeedChildIndex(previous.NextChildIndex); + } builder.OpenComponent>(7); builder.AddComponentParameter(8, "Value", _ownVariantCtx); @@ -169,6 +197,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _dotnet = DotNetObjectReference.Create(this); await InitialiseAsync(); _initialized = true; + // The initial inline style is what the engine seeded the DOM with; mark it reconciled. + _committedStyle = _pendingStyle; } else if (_initialized) { @@ -181,6 +211,17 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _layoutSnapshot = null; await PlayFlipAsync(snap); } + + // If a re-render rewrote the inline style attribute (e.g. the consumer changed Style), + // Blazor will have wiped the engine's live transform/opacity/etc. Re-flush the current + // engine values on top so animated state isn't visibly reset. + if (_pendingStyle != _committedStyle) + { + _committedStyle = _pendingStyle; + var live = Engine.GetCurrentStyles(_id); + if (live is { Count: > 0 }) + await Interop.ApplyStylesAsync(_id, live); + } } } @@ -333,6 +374,10 @@ private async Task PlayFlipAsync(BmotionBoundingRect snap) : BmotionEasingFunctions.ToCssString(t); string? finalT = Engine.GetCurrentTransformString(_id); + // Pause the engine's per-frame transform writes for the duration of the FLIP so the rAF + // loop and the WAAPI layout animation don't both write `transform` and tear each other. + Engine.SuspendTransformWrites(_id, dur); + await Interop.PlayWaapiFlipAsync(_id, dx, dy, sx, sy, dur, easing, finalT); } @@ -442,10 +487,10 @@ public async Task OnPointerDown_Drag() /// Called synchronously from JS to get current XY for drag start offset (Blazor WASM only). [JSInvokable] - public object GetCurrentXY() + public BmotionXY GetCurrentXY() { var (x, y) = Engine.GetCurrentXY(_id); - return new { x, y }; + return new BmotionXY(x, y); } [JSInvokable] public async Task OnDragMove() => await OnDrag.InvokeAsync(); diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor index 9a182c44ee..de24ccdbae 100644 --- a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor @@ -32,7 +32,8 @@ { _ctx.DefaultTransition = Transition; _ctx.ReduceMotion = ReduceMotion; - // Reject negative and non-finite (NaN/Infinity) speeds, which would corrupt duration math. - _ctx.TransitionSpeed = double.IsFinite(TransitionSpeed) && TransitionSpeed >= 0 ? TransitionSpeed : 0; + // The context setter coerces negative / non-finite (NaN/Infinity) speeds to 0, so a bad + // binding can't corrupt duration math or crash the render. + _ctx.TransitionSpeed = TransitionSpeed; } } diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs index fd3e141e12..acfcf76a49 100644 --- a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs @@ -16,18 +16,16 @@ public class BmotionConfigContext /// /// Scale factor applied to all animation durations. 0 = instant, 2 = half speed - /// (durations are multiplied by this factor). Default: 1. Negative values are rejected. + /// (durations are multiplied by this factor). Default: 1. + /// + /// Negative and non-finite (NaN/Infinity) values are coerced to 0 rather than throwing, + /// so a bad binding can never crash a render. This matches 's behaviour. + /// /// public double TransitionSpeed { get => _transitionSpeed; - set - { - if (!double.IsFinite(value) || value < 0) - throw new ArgumentOutOfRangeException(nameof(value), value, - "TransitionSpeed must be a finite, non-negative number."); - _transitionSpeed = value; - } + set => _transitionSpeed = double.IsFinite(value) && value >= 0 ? value : 0; } private double _transitionSpeed = 1.0; } diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs index 4097124b25..5153cee279 100644 --- a/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs @@ -29,6 +29,13 @@ public class BmotionVariantContext /// internal int RegisterChild() => _nextChildIndex++; + /// The next child index that would be handed out. Used to carry the counter across + /// context instances so children registering after a variant change keep stable indices. + internal int NextChildIndex => _nextChildIndex; + + /// Seeds the child-index counter (used when a fresh context instance replaces a prior one). + internal void SeedChildIndex(int value) => _nextChildIndex = value; + /// Returns the stagger delay in seconds for a child at the given index. public double GetChildDelay(int childIndex) => DelayChildren + childIndex * StaggerChildren; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index 48986f03bf..eb72339b94 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -45,6 +45,10 @@ public async ValueTask EnsureReducedMotionDetectedAsync() try { OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync(); + // Subscribe to live OS changes so toggling prefers-reduced-motion at runtime is honoured + // (the value was previously cached for the engine's whole lifetime). + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.WatchReducedMotionAsync(_dotnet); } catch { @@ -54,6 +58,10 @@ public async ValueTask EnsureReducedMotionDetectedAsync() } } + /// JS → C# callback fired when the OS prefers-reduced-motion setting changes. + [JSInvokable] + public void OnReducedMotionChanged(bool prefersReduced) => OsPrefersReducedMotion = prefersReduced; + // ═══════════════════════════════════════════════════════════════════════════ // Element lifecycle // ═══════════════════════════════════════════════════════════════════════════ @@ -144,10 +152,28 @@ public void SetInstant(string elementId, Dictionary values) // Kick the loop for a single frame so the change is flushed to the DOM even when // the element is otherwise at rest (an instant Set has dirty values but no active // animation, so without this it would never be emitted). - _ = EnsureLoopRunningAsync(); + KickLoop(); } } + /// + /// Pauses rAF transform writes for an element for so a WAAPI FLIP + /// layout animation can own the transform without the engine fighting it each frame. + /// + public void SuspendTransformWrites(string elementId, double durationMs) + { + if (_elements.TryGetValue(elementId, out var state)) + state.SuspendTransformWrites(durationMs); + } + + /// + /// Returns a snapshot of the element's current CSS declarations (transform + numeric + string + + /// path values), or null when the element is unknown. Used to re-flush live styles after + /// a Blazor re-render rewrites the element's style attribute. + /// + public Dictionary? GetCurrentStyles(string elementId) + => _elements.TryGetValue(elementId, out var state) ? state.BuildSnapshotStyles() : null; + /// Returns true if an element is currently registered with the engine. public bool IsRegistered(string elementId) => _elements.ContainsKey(elementId); @@ -160,7 +186,7 @@ public void Complete(string elementId) if (_elements.TryGetValue(elementId, out var state)) { state.CompleteAll(); - _ = EnsureLoopRunningAsync(); + KickLoop(); } } @@ -400,6 +426,22 @@ private void StopLoopInternal() _ = _interop.StopRafLoopAsync(_dotnet); } + /// + /// Fire-and-forget loop start that observes (and swallows) any fault. Used by synchronous entry + /// points (Set / SetInstant / Complete) so an unsupported-platform throw can't surface as an + /// unobserved task exception. + /// + private void KickLoop() + { + _ = KickLoopAsync(); + + async Task KickLoopAsync() + { + try { await EnsureLoopRunningAsync(); } + catch { /* loop start is best-effort here; awaited entry points surface real errors */ } + } + } + public async ValueTask DisposeAsync() { foreach (var (_, state) in _elements) @@ -413,6 +455,7 @@ public async ValueTask DisposeAsync() if (_dotnet != null) { try { await _interop.StopRafLoopAsync(_dotnet); } catch { /* ignore during teardown */ } + try { await _interop.UnwatchReducedMotionAsync(_dotnet); } catch { /* ignore during teardown */ } _dotnet.Dispose(); _dotnet = null; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs index a036def56c..10ed613687 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs @@ -31,7 +31,7 @@ public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig conf _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; + _isInfinite = config.IsInfiniteRepeat; _repeatType = config.RepeatType; _repeatDelayMs = config.RepeatDelay * 1000; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs index 3eb34f4017..883b87f54f 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs @@ -32,7 +32,7 @@ public BmotionColorTweenDriver(string from, string to, BmotionTransitionConfig c _delayMs = config.Delay * 1000; _easeFn = BmotionEasingFunctions.Get(config); _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; + _isInfinite = config.IsInfiniteRepeat; _repeatType = config.RepeatType; _repeatDelayMs = config.RepeatDelay * 1000; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs index fa5a85df12..19b1ee7ff4 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -35,10 +35,18 @@ internal sealed class BmotionElementAnimationState private BmotionTransitionConfig? _baseTransition; // ── Animation completion tracking ───────────────────────────────────────── - // Result flag: true = animation finished naturally (or was snapped to its end via - // CompleteAll); false = it was superseded by a new animation or cancelled. Callers use the - // flag to avoid raising "complete" callbacks for interrupted animations. - private TaskCompletionSource? _completionSource; + // Each AnimateTo call that carries a TaskCompletionSource registers a "batch": the set of + // property keys that call is animating. The batch resolves true only when every one of its + // keys finishes naturally; it resolves false if any of its keys is superseded or cancelled. + // Tracking per-batch (rather than a single per-element source) means overlapping animations on + // different properties of the same element no longer resolve each other prematurely. + private sealed class CompletionBatch + { + public required TaskCompletionSource Source { get; init; } + public required HashSet Keys { get; init; } + public bool Interrupted { get; set; } + } + private readonly List _batches = new(); // ── Drag state ──────────────────────────────────────────────────────────── private bool _isDragging; @@ -47,6 +55,12 @@ internal sealed class BmotionElementAnimationState private bool _transformDirty; private readonly HashSet _dirtyProps = new(); + // Transform writes are paused while a WAAPI FLIP layout animation owns the element's + // transform, so the rAF engine doesn't fight (and visually tear) the layout animation. + // The window is measured against Tick timestamps so no extra timer/interop is needed. + private double _transformSuspendMs; + private double _transformSuspendStart = -1; + // Reused across frames to avoid allocating a fresh CSS-update dictionary every rAF tick. // Safe because the synchronous JS interop marshals the returned dictionary before the next // Tick runs (single-threaded Blazor WASM), so the buffer is never read after it is cleared. @@ -77,14 +91,10 @@ internal sealed class BmotionElementAnimationState if (completed != null) foreach (var key in completed) + { _activeAnims.Remove(key); - - // Signal awaiter if all finished - if (_completionSource != null && _activeAnims.Count == 0) - { - _completionSource.TrySetResult(true); // natural completion - _completionSource = null; - } + NotePropFinished(key, interrupted: false); // natural completion + } if (!_transformDirty && _dirtyProps.Count == 0) return null; @@ -92,7 +102,11 @@ internal sealed class BmotionElementAnimationState var updates = _updateBuffer; updates.Clear(); - if (_transformDirty) + // While a FLIP layout animation owns the transform, hold transform writes back so the two + // animators don't fight; the dirty flag is preserved so the latest transform is flushed + // the moment the suspension window ends. + bool transformSuspended = IsTransformSuspended(timestamp); + if (_transformDirty && !transformSuspended) updates["transform"] = BmotionTransformComposer.Build(Transforms); foreach (var prop in _dirtyProps) @@ -130,13 +144,73 @@ internal sealed class BmotionElementAnimationState } } - // Reset dirty flags now that this frame's changes have been emitted. - _transformDirty = false; + // Reset dirty flags now that this frame's changes have been emitted. The transform flag is + // kept set while suspended so the pending transform flushes once the FLIP window ends. + if (!transformSuspended) _transformDirty = false; _dirtyProps.Clear(); return updates.Count > 0 ? updates : null; } + // ── Transform suspension (used by FLIP layout animations) ───────────────── + + /// Pause rAF transform writes for (measured from the next tick). + internal void SuspendTransformWrites(double durationMs) + { + if (durationMs <= 0) return; + _transformSuspendMs = durationMs; + _transformSuspendStart = -1; // armed on the next tick + } + + private bool IsTransformSuspended(double timestamp) + { + if (_transformSuspendMs <= 0) return false; + if (_transformSuspendStart < 0) _transformSuspendStart = timestamp; + if (timestamp - _transformSuspendStart < _transformSuspendMs) return true; + // Window elapsed - clear so transforms resume. + _transformSuspendMs = 0; + _transformSuspendStart = -1; + return false; + } + + /// + /// Builds a full snapshot of the element's current CSS (transform + numeric + string + path + /// values), regardless of dirty flags. Used to re-flush live styles to the DOM after a Blazor + /// re-render rewrites the element's style attribute. + /// + internal Dictionary? BuildSnapshotStyles() + { + var d = new Dictionary(); + + if (Transforms.Count > 0) + { + var tr = BmotionTransformComposer.Build(Transforms); + if (!string.IsNullOrEmpty(tr)) d["transform"] = tr; + } + + bool hasPath = NumericValues.ContainsKey("pathLength") + || NumericValues.ContainsKey("pathSpacing") + || NumericValues.ContainsKey("pathOffset"); + if (hasPath) + { + double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); + double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0); + double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); + d["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing); + d["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); + } + + foreach (var (prop, value) in NumericValues) + { + if (prop is "pathLength" or "pathSpacing" or "pathOffset") continue; + d[prop] = BmotionCssFormat.Num(value); + } + foreach (var (prop, value) in StringValues) + d[prop] = value; + + return d.Count > 0 ? d : null; + } + // ═══════════════════════════════════════════════════════════════════════════ // Animation control // ═══════════════════════════════════════════════════════════════════════════ @@ -152,18 +226,19 @@ public void AnimateTo( if (v != null) { any = true; break; } if (!any) { completionSource?.TrySetResult(true); return; } - // Complete any previously-pending awaiter so callers aren't stranded when a - // new animation supersedes the old one. The old one resolves with false so its - // completion callback (OnAnimationComplete) is suppressed - it was interrupted. - if (_completionSource != null && !ReferenceEquals(_completionSource, completionSource)) - _completionSource.TrySetResult(false); - _completionSource = completionSource; + // Track which keys actually get an active driver so the completion batch only waits on + // animations that can finish (keys that snap instantly resolve immediately). + HashSet? driverKeys = completionSource != null ? new HashSet() : null; + int activeBefore; foreach (var (key, value) in values) { if (value == null) continue; var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new BmotionTransitionConfig(); + // Superseding an in-flight driver for this key interrupts any completion batch that + // owned it (resolves that batch with false once its remaining keys settle). CancelProp(key); + activeBefore = _activeAnims.Count; if (TryGetDoubleArray(value, out double[]? doubleFrames)) { @@ -194,8 +269,24 @@ public void AnimateTo( StringValues[key] = otherFrames[^1]; _dirtyProps.Add(key); } + else if (TryConvertToDouble(value, out double numeric)) + CreateNumericDriver(key, numeric, perKey); + // else: an unconvertible value (e.g. an arbitrary object in a user Keyframes dict) is + // skipped rather than throwing - a bad value can't take down the init / event path. + + // Record the key if this iteration created a live driver for the completion batch. + if (driverKeys != null && _activeAnims.Count > activeBefore && _activeAnims.ContainsKey(key)) + driverKeys.Add(key); + } + + // Register the completion batch (if any): it resolves once every key that got a driver + // finishes. If nothing animates (all snapped instantly), resolve immediately. + if (completionSource != null) + { + if (driverKeys is { Count: > 0 }) + _batches.Add(new CompletionBatch { Source = completionSource, Keys = driverKeys }); else - CreateNumericDriver(key, Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture), perKey); + completionSource.TrySetResult(true); } } @@ -209,8 +300,11 @@ public void SetInstant(Dictionary values) CancelProp(key); if (BmotionTransformComposer.IsTransformProp(key)) { - Transforms[key] = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); - _transformDirty = true; + if (TryConvertToDouble(value, out double tv)) + { + Transforms[key] = tv; + _transformDirty = true; + } } else if (IsColorProp(key) && value is string colorStr) { @@ -222,9 +316,9 @@ public void SetInstant(Dictionary values) StringValues[key] = dimStr; _dirtyProps.Add(key); } - else + else if (TryConvertToDouble(value, out double nv)) { - NumericValues[key] = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + NumericValues[key] = nv; _dirtyProps.Add(key); } } @@ -236,15 +330,10 @@ public void Cancel(string[]? properties) CancelAll(); else { + // CancelProp interrupts any completion batch that owns the property, so callers of + // AnimateToAwaitAsync resolve (with false) instead of hanging forever. foreach (var p in properties) CancelProp(p); - // If those were the only running animations, resolve any pending awaiter so callers - // of AnimateToAwaitAsync don't hang forever (matches CancelAll's behaviour). - if (_activeAnims.Count == 0 && _completionSource != null) - { - _completionSource.TrySetResult(false); // cancelled, not completed - _completionSource = null; - } } } @@ -253,8 +342,7 @@ internal void CancelAll() foreach (var driver in _activeAnims.Values) driver.Cancel(); _activeAnims.Clear(); - _completionSource?.TrySetResult(false); // cancelled, not completed - _completionSource = null; + ResolveAllBatches(false); // cancelled, not completed } /// @@ -267,8 +355,7 @@ internal void CompleteAll() foreach (var driver in _activeAnims.Values) driver.Complete(); _activeAnims.Clear(); - _completionSource?.TrySetResult(true); // snapped to end values = completed - _completionSource = null; + ResolveAllBatches(true); // snapped to end values = completed } internal void CancelProp(string key) @@ -277,6 +364,50 @@ internal void CancelProp(string key) { driver.Cancel(); _activeAnims.Remove(key); + NotePropFinished(key, interrupted: true); // cancelled / superseded + } + } + + // ── Completion-batch bookkeeping ────────────────────────────────────────── + + /// + /// Records that is no longer animating. Removes it from any pending + /// completion batch; a batch resolves once all its keys are gone (true only if none of them + /// were interrupted - i.e. every key finished naturally). + /// + private void NotePropFinished(string key, bool interrupted) + { + for (int i = _batches.Count - 1; i >= 0; i--) + { + var b = _batches[i]; + if (!b.Keys.Remove(key)) continue; + if (interrupted) b.Interrupted = true; + if (b.Keys.Count == 0) + { + b.Source.TrySetResult(!b.Interrupted); + _batches.RemoveAt(i); + } + } + } + + private void ResolveAllBatches(bool result) + { + foreach (var b in _batches) + b.Source.TrySetResult(result); + _batches.Clear(); + } + + private static bool TryConvertToDouble(object value, out double result) + { + try + { + result = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException) + { + result = 0; + return false; } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index 1f339fc4d7..d2fa0b0e34 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -30,7 +30,7 @@ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig co _durationMs = config.Duration * 1000; _delayMs = config.Delay * 1000; _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; + _isInfinite = config.IsInfiniteRepeat; _repeatType = config.RepeatType; _repeatDelayMs = config.RepeatDelay * 1000; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs index 0831568ba6..99a82e0634 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs @@ -54,7 +54,7 @@ public BmotionSpringDriver(double from, double to, BmotionTransitionConfig confi _currentDelayMs = config.Delay * 1000; _repeatDelayMs = config.RepeatDelay * 1000; _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; + _isInfinite = config.IsInfiniteRepeat; _repeatType = config.RepeatType; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs index abf9baa990..a0346b9908 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs @@ -27,7 +27,7 @@ public BmotionTweenDriver(double from, double to, BmotionTransitionConfig config _delayMs = config.Delay * 1000; _easeFn = BmotionEasingFunctions.Get(config); _repeat = config.Repeat; - _isInfinite = config.Repeat == int.MaxValue; + _isInfinite = config.IsInfiniteRepeat; _repeatType = config.RepeatType; _repeatDelayMs = config.RepeatDelay * 1000; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs index b32bf712f9..2f9091a09e 100644 --- a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs @@ -51,6 +51,20 @@ public async ValueTask StopRafLoopAsync(DotNetObjectReference? dotnetRef = public async ValueTask PrefersReducedMotionAsync() => await (await Module()).InvokeAsync("prefersReducedMotion"); + /// + /// Subscribes to live changes of the prefers-reduced-motion media query. JS calls + /// OnReducedMotionChanged(bool) on the engine ref whenever the OS preference changes. + /// + public async ValueTask WatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeVoidAsync("watchReducedMotion", dotnetRef); + + /// Unsubscribes the engine ref from prefers-reduced-motion change notifications. + public async ValueTask UnwatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("unwatchReducedMotion", dotnetRef); + } + // ── Style application ───────────────────────────────────────────────────── /// Instantly apply a CSS styles object to a DOM element (for set() calls). diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs new file mode 100644 index 0000000000..f58ac861ec --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs @@ -0,0 +1,17 @@ +namespace Bit.Bmotion; + +/// +/// A simple x/y pair marshalled to JS at drag start (serialised as { x, y }). +/// Used instead of a boxed anonymous type so the shape is explicit and trim/AOT friendly. +/// +public readonly struct BmotionXY +{ + public BmotionXY(double x, double y) + { + X = x; + Y = y; + } + + public double X { get; init; } + public double Y { get; init; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs index 391c8a1d1e..d5f428d7d9 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -38,9 +38,21 @@ public double[]? EaseCubicBezier } // ── Repeat ──────────────────────────────────────────────────────────────── - /// Number of times to repeat. Set to int.MaxValue for infinite. + /// + /// Number of times to repeat. Setting (preferred) or the legacy + /// sentinel int.MaxValue makes the animation repeat forever. + /// public int Repeat { get; set; } = 0; + /// + /// When true the animation repeats forever, regardless of . + /// Prefer this over the legacy Repeat = int.MaxValue sentinel. + /// + public bool RepeatInfinite { get; set; } + + /// True when this transition repeats forever (via flag or legacy sentinel). + internal bool IsInfiniteRepeat => RepeatInfinite || Repeat == int.MaxValue; + /// How to repeat: Loop, Mirror (ping-pong), or Reverse. public BmotionRepeatType RepeatType { get; set; } = BmotionRepeatType.Loop; @@ -144,6 +156,7 @@ public double[]? EaseCubicBezier Ease = Ease, EaseCubicBezier = EaseCubicBezier is null ? null : (double[])EaseCubicBezier.Clone(), Repeat = Repeat, + RepeatInfinite = RepeatInfinite, RepeatType = RepeatType, RepeatDelay = RepeatDelay, Times = Times is null ? null : (double[])Times.Clone(), diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs index 1672ac4a00..1e7a04a351 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs @@ -105,13 +105,24 @@ private BmotionAnimationControls StartAnimations( var completion = Task.WhenAll(completionTasks); - // Release our refcount on every target once the animations settle. - _ = completion.ContinueWith(_ => + // The controls own the single refcount release (idempotent). It fires whichever happens + // first: natural completion, Stop(), or Complete(). This is what prevents an + // infinite-repeat animation - whose completion task never resolves - from leaking refcounts. + var released = false; + void Release() { + if (released) return; + released = true; foreach (var id in elementIds) _engine.UnregisterElement(id); - }, TaskScheduler.Default); + } - return new BmotionAnimationControls(elementIds, _engine, completion); + var controls = new BmotionAnimationControls(elementIds, _engine, completion, Release); + + _ = completion.ContinueWith( + _ => controls.OnCompletionSettled(), + TaskScheduler.Default); + + return controls; } } diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs index 24d7cd2853..7ef3e5a95d 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs @@ -5,6 +5,13 @@ namespace Bit.Bmotion; /// Analogous to Framer Motion's useAnimate(). /// Obtain via DI (@inject BmotionAnimationController) and bind to an element ID. /// All animation math runs in the C# . +/// +/// Lifetime / disposal: registered Transient and meant to be owned by a single +/// component. When injected with @inject, Blazor resolves it from the root scope and only +/// disposes it at app shutdown, so the consuming component must dispose it explicitly +/// (implement and call from the component's +/// Dispose) - otherwise the bound element stays registered with the engine until the app ends. +/// /// public sealed class BmotionAnimationController : IDisposable { diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs index 499c1cf3a0..5f61a5eef8 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs @@ -5,18 +5,36 @@ namespace Bit.Bmotion; /// Controls for an in-flight programmatic animation started by /// . /// The object is directly awaitable - await controls waits for the animation to complete. +/// +/// freezes the animation at its current (intermediate) values; +/// jumps it to its target (end) values. Both release the engine refcount immediately, so they also +/// safely stop infinite-repeat animations (whose completion task never resolves on its own). +/// /// public sealed class BmotionAnimationControls { private readonly IReadOnlyList _elementIds; private readonly BmotionAnimationEngine _engine; private readonly Task _completion; + private readonly Action _release; + private int _released; - internal BmotionAnimationControls(IReadOnlyList elementIds, BmotionAnimationEngine engine, Task completion) + internal BmotionAnimationControls( + IReadOnlyList elementIds, BmotionAnimationEngine engine, Task completion, Action release) { _elementIds = elementIds; _engine = engine; _completion = completion; + _release = release; + } + + // Release the engine refcount exactly once - whether the animation finishes naturally, is + // stopped, or is completed early. Without this, an infinite-repeat animation (which never + // finishes) would pin its elements in the engine forever. + private void ReleaseOnce() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) == 0) + _release(); } /// @@ -27,6 +45,7 @@ public void Stop() { foreach (var id in _elementIds) _engine.Stop(id, null); + ReleaseOnce(); } /// @@ -36,6 +55,7 @@ public void Complete() { foreach (var id in _elementIds) _engine.Complete(id); + ReleaseOnce(); } /// A that resolves when all animations finish naturally. @@ -43,4 +63,8 @@ public void Complete() /// Makes directly awaitable. public TaskAwaiter GetAwaiter() => _completion.GetAwaiter(); + + // Invoked by the owning service once the completion task settles, so a natural finish also + // releases the refcount (idempotent with Stop/Complete). + internal void OnCompletionSettled() => ReleaseOnce(); } diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs index 8cdaf725b3..d6a0dd8f17 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -5,21 +5,34 @@ namespace Bit.Bmotion; /// Tracks scroll progress (0–1) for a container element or the window. /// Analogous to Framer Motion's useScroll. /// +/// +/// Lifetime / disposal: this service is registered Transient and is meant to be +/// owned by a single component. When injected with @inject, Blazor resolves it from the +/// root scope and only disposes it at app shutdown - so the consuming component must dispose it +/// explicitly (implement and call in +/// the component's DisposeAsync), otherwise its JS scroll subscription and +/// DotNetObjectReference leak until the app ends. The reference is created lazily, so an +/// injected-but-unused tracker holds no JS resources. +/// +/// /// Usage: /// +/// @implements IAsyncDisposable /// @inject BmotionScrollTracker Scroll /// /// protected override async Task OnAfterRenderAsync(bool firstRender) /// { /// if (firstRender) await Scroll.ObserveAsync(null, info => scrollY = info.ProgressY); /// } +/// +/// public ValueTask DisposeAsync() => Scroll.DisposeAsync(); /// /// public sealed class BmotionScrollTracker : IAsyncDisposable { private readonly BmotionInterop _interop; private readonly List _subscriptionKeys = new(); - private readonly DotNetObjectReference _dotnet; + private DotNetObjectReference? _dotnet; private Func? _onScroll; private bool _disposed; @@ -27,8 +40,11 @@ public sealed class BmotionScrollTracker : IAsyncDisposable public BmotionScrollTracker(BmotionInterop interop) { _interop = interop; - _dotnet = DotNetObjectReference.Create(this); } + + // Created on first use so an injected-but-unused tracker doesn't allocate a JS-object reference. + private DotNetObjectReference Dotnet + => _dotnet ??= DotNetObjectReference.Create(this); /// Horizontal scroll progress 0–1. public double ProgressX { get; private set; } @@ -54,7 +70,7 @@ public async Task ObserveAsync(string? containerId, Func { + for (const ref of _reducedMotionRefs) { + try { ref.invokeMethodAsync('OnReducedMotionChanged', e.matches); } + catch { /* a disposed/faulted engine ref must not break the others */ } + } + }; + // addEventListener is the modern API; addListener is the deprecated Safari fallback. + if (_reducedMotionMql.addEventListener) _reducedMotionMql.addEventListener('change', _reducedMotionListener); + else if (_reducedMotionMql.addListener) _reducedMotionMql.addListener(_reducedMotionListener); +} + +export function watchReducedMotion(dotnetRef) { + _reducedMotionRefs.add(dotnetRef); + _ensureReducedMotionListener(); +} + +export function unwatchReducedMotion(dotnetRef) { + _reducedMotionRefs.delete(dotnetRef); + if (_reducedMotionRefs.size === 0 && _reducedMotionMql && _reducedMotionListener) { + if (_reducedMotionMql.removeEventListener) _reducedMotionMql.removeEventListener('change', _reducedMotionListener); + else if (_reducedMotionMql.removeListener) _reducedMotionMql.removeListener(_reducedMotionListener); + _reducedMotionMql = null; + _reducedMotionListener = null; + } +} + // // Element registration // @@ -133,8 +168,8 @@ export function unregisterElement(elementId) { const el = document.getElementById(elementId); if (el) el.removeAttribute('data-bmid'); _runCleanup(elementId); - // _vpObservers is keyed by option signature, so unobserve from every observer. - if (el) _vpObservers.forEach(obs => obs.unobserve(el)); + // Detach from every viewport observer (drops membership and evicts empty observers). + _detachFromObservers(el, elementId); _vpRefs.delete(elementId); } @@ -359,29 +394,46 @@ function _attachDrag(elementId, el, opts, dotnetRef, cleanups) { // Viewport observation (whileInView) // -// Cache observers keyed by their options signature so we can re-use them. -const _vpObservers = new Map(); // sig → IntersectionObserver +// Cache observers keyed by their options signature so we can re-use them. Each entry tracks the +// element IDs it currently observes so the observer can be disconnected once it falls empty +// (otherwise distinct margin/threshold combinations would accumulate observers forever). +const _vpObservers = new Map(); // sig → { observer, members: Set } const _vpRefs = new Map(); // elementId → { dotnetRef, once } function _vpSig(margin, threshold) { return `${margin}|${threshold}`; } -function _getVpObserver(margin, threshold) { +function _getVpEntry(margin, threshold) { const sig = _vpSig(margin, threshold); - if (_vpObservers.has(sig)) return _vpObservers.get(sig); - const obs = new IntersectionObserver((entries) => { + let entry = _vpObservers.get(sig); + if (entry) return entry; + const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const id = entry.target.getAttribute('data-bmid'); const ref = _vpRefs.get(id); if (!ref) continue; ref.dotnetRef.invokeMethodAsync('OnIntersect', entry.isIntersecting); if (ref.once && entry.isIntersecting) { - obs.unobserve(entry.target); + _detachFromObservers(entry.target, id); _vpRefs.delete(id); } } }, { rootMargin: margin || '0px', threshold: threshold ?? 0 }); - _vpObservers.set(sig, obs); - return obs; + entry = { observer, members: new Set() }; + _vpObservers.set(sig, entry); + return entry; +} + +// Unobserve an element from every observer that might track it, dropping membership and +// disconnecting (and evicting) any observer left with no members. +function _detachFromObservers(el, elementId) { + for (const [sig, entry] of _vpObservers) { + if (el) entry.observer.unobserve(el); + entry.members.delete(elementId); + if (entry.members.size === 0) { + entry.observer.disconnect(); + _vpObservers.delete(sig); + } + } } export function observeViewport(elementId, dotnetRef, options) { @@ -393,18 +445,16 @@ export function observeViewport(elementId, dotnetRef, options) { // Detach from any previously assigned observer first so re-observing with different options // doesn't stack duplicate subscriptions (which would fire OnIntersect multiple times and // break the "once" behaviour across option changes). - _vpObservers.forEach(obs => obs.unobserve(el)); + _detachFromObservers(el, elementId); _vpRefs.set(elementId, { dotnetRef, once }); - _getVpObserver(margin, threshold).observe(el); + const entry = _getVpEntry(margin, threshold); + entry.members.add(elementId); + entry.observer.observe(el); } export function unobserveViewport(elementId) { const el = document.getElementById(elementId); - const ref = _vpRefs.get(elementId); - if (el && ref) { - // unobserve from every observer that might track this element - _vpObservers.forEach(obs => obs.unobserve(el)); - } + _detachFromObservers(el, elementId); _vpRefs.delete(elementId); } From 551b1f023aa489b611819f6806e0f7d6bafea582 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 07:51:50 +0330 Subject: [PATCH 11/24] resolve review comments V --- .../Bit.Bmotion.Demos/Pages/DragPage.razor | 4 ++-- .../Bit.Bmotion.Demos/Pages/Gestures.razor | 3 +++ src/Bmotion/Bit.Bmotion/Components/Bmotion.cs | 6 +++-- .../Engine/BmotionAnimationEngine.cs | 4 +++- .../Engine/BmotionEasingFunctions.cs | 2 +- .../Engine/BmotionElementAnimationState.cs | 23 +++++++++++++++---- .../Engine/BmotionInertiaDriver.cs | 4 +++- .../Engine/BmotionNumericKeyframesDriver.cs | 12 ++++++++++ .../Bit.Bmotion/Engine/BmotionSpringDriver.cs | 4 +++- .../Services/BmotionScrollTracker.cs | 3 +++ .../Bit.Bmotion/Services/BmotionValue.cs | 3 +++ 11 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor index 7fe8e1362f..5078abb657 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor @@ -43,7 +43,7 @@

Drag Events

-

Listen to OnDragStart, OnDragEnd for real-time position feedback.

+

Listen to OnDragStart, OnDragEnd lifecycle callbacks to react to drag state transitions.

Drag Events

-

Listen to OnDragStart, OnDragEnd for real-time position feedback.

+

Listen to OnDragStart, OnDragEnd lifecycle callbacks to react to drag state transitions.

8) + _events.RemoveRange(0, _events.Count - 8); StateHasChanged(); } } diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs index 27fd95538e..a4c39e62be 100644 --- a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -581,8 +581,10 @@ private bool NeedsPathLengthAttr() => HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) || HasPathLength(WhileInView) || HasPathLength(WhileDrag)); - private static bool HasPathLength(BmotionAnimationTarget? t) => - t?.Props?.PathLength != null; + // Resolve the effective props (direct or variant-referenced) so pathLength is detected + // whether the target carries Props directly or points at a variant label. + private bool HasPathLength(BmotionAnimationTarget? t) => + ResolveProps(t)?.PathLength != null; private BmotionAnimationProps? ResolveProps(BmotionAnimationTarget? target) { diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index eb72339b94..d373357f77 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -41,7 +41,6 @@ public sealed class BmotionAnimationEngine : IAsyncDisposable public async ValueTask EnsureReducedMotionDetectedAsync() { if (_reducedMotionDetected) return; - _reducedMotionDetected = true; try { OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync(); @@ -49,6 +48,9 @@ public async ValueTask EnsureReducedMotionDetectedAsync() // (the value was previously cached for the engine's whole lifetime). _dotnet ??= DotNetObjectReference.Create(this); await _interop.WatchReducedMotionAsync(_dotnet); + // Only mark detection complete once both interop calls succeed, so a transient + // failure leaves the flag unset and a later call can retry. + _reducedMotionDetected = true; } catch { diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs index 9d5a267fbd..4aa6d5f071 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs @@ -46,7 +46,7 @@ public static string ToCssString(BmotionTransitionConfig? config) { if (config == null) return "ease"; if (config.EaseCubicBezier is { Length: 4 } cb) - return $"cubic-bezier({cb[0]},{cb[1]},{cb[2]},{cb[3]})"; + return $"cubic-bezier({BmotionCssFormat.Num(cb[0])},{BmotionCssFormat.Num(cb[1])},{BmotionCssFormat.Num(cb[2])},{BmotionCssFormat.Num(cb[3])})"; return config.Ease switch { BmotionEasing.Linear => "linear", diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs index 19b1ee7ff4..b45348e22d 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -11,7 +11,9 @@ internal sealed class BmotionElementAnimationState // ── Live CSS values ─────────────────────────────────────────────────────── /// Current values of transform components (x, y, scale, rotate, …). - internal readonly Dictionary Transforms = new(); + // Case-insensitive so keys accepted by BmotionTransformComposer.IsTransformProp (which compares + // OrdinalIgnoreCase) match the canonical lowercase keys the composer reads when emitting. + internal readonly Dictionary Transforms = new(StringComparer.OrdinalIgnoreCase); /// Current values of numeric non-transform properties (opacity, pathLength, …). internal readonly Dictionary NumericValues = new(); @@ -81,10 +83,14 @@ private sealed class CompletionBatch if (_isDragging) _transformDirty = true; // drag always refreshes transform - // Advance all drivers. Only allocate the "completed" list when something finishes. + // Advance all drivers. Iterate over a snapshot because driver.Tick can invoke a user + // OnUpdate callback that re-enters and mutates _activeAnims (e.g. starts/cancels an + // animation on this same element), which would otherwise corrupt the enumeration. List? completed = null; - foreach (var (key, driver) in _activeAnims) + foreach (var (key, driver) in _activeAnims.ToArray()) { + // The driver may have been removed by a re-entrant callback earlier in this loop. + if (!_activeAnims.ContainsKey(key)) continue; if (driver.Tick(timestamp)) (completed ??= new List()).Add(key); } @@ -593,7 +599,16 @@ private static bool TryGetDoubleArray(object? value, out double[]? result) if (value is IEnumerable de) { result = de.ToArray(); return true; } if (value is object[] oa && oa.Length > 0 && oa[0] is double or float or int or long) { - result = oa.Select(x => Convert.ToDouble(x, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + // Convert each element defensively: a mixed array like [0, "bad"] must not throw. + var arr = new double[oa.Length]; + for (int i = 0; i < oa.Length; i++) + { + if (oa[i] is null) return false; + try { arr[i] = Convert.ToDouble(oa[i], System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException) + { return false; } + } + result = arr; return true; } // Any other numeric sequence (int[], float[], List, …). Strings are excluded so diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs index e0599e7733..e2d6fa8010 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs @@ -23,7 +23,9 @@ public BmotionInertiaDriver(double from, BmotionTransitionConfig config, Action< { _start = from; _timeConstantSec = config.TimeConstant > 0 ? config.TimeConstant / 1000.0 : 1e-6; - _restDelta = config.InertiaRestDelta; + // Rest delta must be strictly positive, otherwise the completion test + // (|projected - pos| < restDelta) can never pass and the driver runs forever. + _restDelta = config.InertiaRestDelta > 0 ? config.InertiaRestDelta : 0.01; _delayMs = config.Delay * 1000; _apply = apply; diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index d2fa0b0e34..ff92793f88 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -24,6 +24,18 @@ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig co throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames)); if (config.Times != null && config.Times.Length != frames.Length) throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); + if (config.Times != null) + { + // Times feed the Interpolate segment math; non-monotonic or out-of-range values produce + // negative/zero segment lengths and NaN output, so reject them up front. + for (int i = 0; i < config.Times.Length; i++) + { + if (config.Times[i] < 0 || config.Times[i] > 1) + throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config)); + if (i > 0 && config.Times[i] < config.Times[i - 1]) + throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config)); + } + } _frames = frames; _curFrames = (double[])frames.Clone(); diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs index 99a82e0634..33e4f8162d 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs @@ -47,7 +47,9 @@ public BmotionSpringDriver(double from, double to, BmotionTransitionConfig confi _k = k; _d = d; - _m = config.Mass; + // Mass divides the acceleration each sub-step; a value <= 0 would yield NaN/Infinity and + // trap the spring (the rest test would never pass). Fall back to the default mass of 1. + _m = config.Mass > 0 ? config.Mass : 1.0; _vel = _initialVel = config.Velocity; _restSpeed = config.RestSpeed; _restDelta = config.RestDelta; diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs index d6a0dd8f17..cf92960d7a 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -86,6 +86,9 @@ public Task ObserveAsync(string? containerId, Action onChange [JSInvokable] public async Task OnScroll(BmotionScrollInfo info) { + // info crosses the JS→C# boundary; guard against a null payload so the property reads below + // don't throw a NullReferenceException inside the interop callback. + if (info is null) return; ProgressX = info.ProgressX; ProgressY = info.ProgressY; ScrollX = info.ScrollX; diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs index 4593f76a1c..2349a1720d 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs @@ -95,6 +95,7 @@ public IDisposable Subscribe(Action callback) ///
public BmotionValue Transform(Func fn) where TOut : struct { + ArgumentNullException.ThrowIfNull(fn); var derived = new BmotionValue($"{_id}_t", fn(_value)); // Keep the parent→derived link so it can be torn down when the derived value is disposed, // otherwise the parent would hold the derived value alive indefinitely (a leak). @@ -107,6 +108,8 @@ public BmotionValue Transform(Func fn) where TOut : struct /// public BmotionValue Transform(double[] inputRange, double[] outputRange) { + ArgumentNullException.ThrowIfNull(inputRange); + ArgumentNullException.ThrowIfNull(outputRange); if (!_numericTypes.Contains(typeof(T))) throw new ArgumentException( $"Transform(inputRange, outputRange) only supports numeric value types; '{typeof(T).Name}' is not numeric."); From c378a5a346046f3db40cd5a2b420b10d1cf0d7a2 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 08:32:41 +0330 Subject: [PATCH 12/24] resolve local review comments III --- src/Bmotion/Bit.Bmotion/BitBmotion.cs | 17 +++ src/Bmotion/Bit.Bmotion/Components/Bmotion.cs | 136 +++++++++++++++--- .../Engine/BmotionAnimationEngine.cs | 55 +++++-- .../Engine/BmotionNumericKeyframesDriver.cs | 4 +- .../Bit.Bmotion/Engine/BmotionSpringDriver.cs | 9 +- .../Models/BmotionAnimationProps.cs | 6 + .../Models/BmotionTransitionConfig.cs | 7 + src/Bmotion/Bit.Bmotion/README.md | 35 +++++ .../Bit.Bmotion/Services/BmotionValue.cs | 30 +++- .../Bit.Bmotion/wwwroot/bit-bmotion.js | 8 +- 10 files changed, 270 insertions(+), 37 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs index 603d68e993..faa3f394cb 100644 --- a/src/Bmotion/Bit.Bmotion/BitBmotion.cs +++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; namespace Bit.Bmotion; @@ -28,6 +29,12 @@ public static IServiceCollection AddBitBmotionServices(this IServiceCollection s { ArgumentNullException.ThrowIfNull(services); + // Keep the JSON-marshaled interop DTOs trim/AOT-safe. These types cross the JS↔.NET + // boundary via reflection-based System.Text.Json (InvokeAsync, [JSInvokable] params), + // so the trimmer can't see their members as used and would otherwise strip them - leading + // to empty/failed deserialization in published WebAssembly builds. + PreserveInteropContracts(); + // Slim browser-API interop bridge - one instance per DI scope services.AddScoped(); @@ -46,4 +53,14 @@ public static IServiceCollection AddBitBmotionServices(this IServiceCollection s return services; } + + // Roots the public properties + parameterless constructors of every type that is (de)serialized + // across JS interop so the trimmer preserves them. The [DynamicDependency] attributes take + // effect because this method is reachable from AddBitBmotionServices (an app entry point). + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionBoundingRect))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionScrollInfo))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionXY))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionViewportOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionDragConstraints))] + private static void PreserveInteropContracts() { } } diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs index a4c39e62be..095968b202 100644 --- a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -99,6 +99,11 @@ public sealed class Bmotion : ComponentBase, IAsyncDisposable // style, so we re-flush the engine's live values on top to avoid resetting animated props. private string _pendingStyle = string.Empty; private string _committedStyle = string.Empty; + // Signatures of the gesture-event flags and viewport options currently wired up in JS. Compared + // each update so listeners/observers are re-attached only when the effective configuration + // changes (gestures are otherwise wired once and would ignore later parameter changes). + private string _eventFlagsSig = string.Empty; + private string? _viewportSig; // ════════════════════════════════════════════════════════════════════════════ // Rendering @@ -180,11 +185,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) private string BuildInitialStyle() { + // The initial inline style exists only to avoid a flash of unstyled content before interop + // initialises; it never changes after the first paint (the engine owns live styles from + // then on), so compute it once and reuse the cached string on subsequent renders. + if (_initialStyleCache != null) return _initialStyleCache; var props = ResolveProps(Initial); if (props == null && Animate == null && VariantCtx?.InitialVariant is string initVariant) props = Variants?.Get(initVariant) ?? VariantCtx.Variants?.Get(initVariant); - return props?.ToCssStyleString() ?? string.Empty; + return _initialStyleCache = props?.ToCssStyleString() ?? string.Empty; } + private string? _initialStyleCache; // ════════════════════════════════════════════════════════════════════════════ // Lifecycle @@ -263,19 +273,10 @@ private async Task InitialiseAsync() PresenceCtx?.Register(this); - // Attach events the JS bridge needs to listen to - var events = BuildEventFlags(); - if (events.Count > 0) - await Interop.AttachEventListenersAsync(_id, events, _dotnet!); - - // Viewport observation - JS IntersectionObserver callbacks C# - if (WhileInView != null || OnViewportEnter.HasDelegate || OnViewportLeave.HasDelegate) - { - if (Viewport != null) - await Interop.ObserveViewportWithOptionsAsync(_id, _dotnet!, Viewport); - else - await Interop.ObserveViewportAsync(_id, _dotnet!, Once); - } + // Attach gesture listeners + viewport observation through the same reconciliation path used + // on later updates, so enabling/disabling a gesture after first render is handled uniformly. + await ReconcileEventListenersAsync(); + await ReconcileViewportAsync(); // Start enter animation if (Animate != null) @@ -285,7 +286,7 @@ private async Task InitialiseAsync() { await OnAnimationStart.InvokeAsync(); await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), - () => OnAnimationComplete.InvokeAsync()); + () => OnAnimationComplete.InvokeAsync(), setAsBase: true); } } else if (VariantCtx != null && (Variants != null || VariantCtx.Variants != null)) @@ -300,7 +301,8 @@ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTr var props = Variants?.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant); if (props != null) await Engine.AnimateToAsync(_id, props.ToJsDictionary(), - BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex))); + BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex)), + setAsBase: true); } } @@ -311,6 +313,23 @@ private async Task HandleParameterUpdateAsync() { if (_isExiting) return; + // Recovery: if the engine evicted this element after a driver fault (see + // BmotionAnimationEngine.ComputeFrame), it silently stopped animating. Re-register and + // re-seed it here so a subsequent parameter change brings it back to life. + if (!Engine.IsRegistered(_id)) + { + var seed = ResolveProps(Initial); + Engine.RegisterElement(_id, seed?.ToJsDictionary()); + _prevAnimate = null; // force the animate below to replay + _prevInheritedVariant = null; // and the variant path too + } + + // Gesture listeners and viewport observation are wired once at init; re-wire them when the + // set of needed events / viewport options changes so gestures enabled (or disabled) after + // the first render actually take effect. + await ReconcileEventListenersAsync(); + await ReconcileViewportAsync(); + if (!BmotionAnimationTarget.AreEquivalent(_prevAnimate, Animate)) { var animateProps = ResolveProps(Animate); @@ -318,7 +337,7 @@ private async Task HandleParameterUpdateAsync() { await OnAnimationStart.InvokeAsync(); await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), - () => OnAnimationComplete.InvokeAsync()); + () => OnAnimationComplete.InvokeAsync(), setAsBase: true); } _prevAnimate = Animate; } @@ -335,7 +354,7 @@ await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTr { double delay = _variantChildIndex >= 0 ? VariantCtx!.GetChildDelay(_variantChildIndex) : 0; await Engine.AnimateToAsync(_id, props.ToJsDictionary(), - BuildEffectiveTransitionWithDelay(delay)); + BuildEffectiveTransitionWithDelay(delay), setAsBase: true); } } } @@ -671,6 +690,87 @@ private BmotionTransitionConfig BuildEffectiveTransitionWithDelay(double extraDe return d; } + /// + /// Re-wires the JS gesture listeners when the effective event set changes. Attaching always + /// runs the JS-side cleanup first, so passing an empty set also safely detaches everything. + /// + private async Task ReconcileEventListenersAsync() + { + var events = BuildEventFlags(); + var sig = SignatureOf(events); + if (sig == _eventFlagsSig) return; + _eventFlagsSig = sig; + await Interop.AttachEventListenersAsync(_id, events, _dotnet!); + } + + /// Re-observes (or stops observing) the viewport when the effective options change. + private async Task ReconcileViewportAsync() + { + var sig = BuildViewportSignature(); + if (sig == _viewportSig) return; + _viewportSig = sig; + if (sig == null) + { + await Interop.UnobserveViewportAsync(_id); + return; + } + if (Viewport != null) + await Interop.ObserveViewportWithOptionsAsync(_id, _dotnet!, Viewport); + else + await Interop.ObserveViewportAsync(_id, _dotnet!, Once); + } + + private string? BuildViewportSignature() + { + bool needed = WhileInView != null || OnViewportEnter.HasDelegate || OnViewportLeave.HasDelegate; + if (!needed) return null; + return Viewport != null + ? $"opt|{Viewport.Once}|{Viewport.Margin}|{Viewport.Amount}" + : $"once|{Once}"; + } + + /// Builds a stable, order-independent string signature for an event-flags dictionary. + private static string SignatureOf(Dictionary d) + { + var sb = new System.Text.StringBuilder(); + foreach (var key in d.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + sb.Append(key).Append('='); + AppendValue(sb, d[key]); + sb.Append(';'); + } + return sb.ToString(); + } + + private static void AppendValue(System.Text.StringBuilder sb, object? value) + { + switch (value) + { + case null: + sb.Append("null"); + break; + case double dbl: + sb.Append(BmotionCssFormat.Num(dbl)); + break; + case bool b: + sb.Append(b ? "1" : "0"); + break; + case IDictionary nested: + sb.Append('{'); + foreach (var key in nested.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + sb.Append(key).Append(':'); + AppendValue(sb, nested[key]); + sb.Append(','); + } + sb.Append('}'); + break; + default: + sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); + break; + } + } + // ════════════════════════════════════════════════════════════════════════════ // Dispose // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index d373357f77..2abf044d6e 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; namespace Bit.Bmotion; @@ -10,9 +11,22 @@ namespace Bit.Bmotion; /// requestAnimationFrame tick and receives back a dictionary of /// CSS style updates to apply to the DOM. /// +/// +/// +/// Threading: the engine is intentionally lock-free. All of its mutable state +/// (the element map, per-element driver dictionaries, completion batches, dirty flags) is only +/// safe to touch from a single thread, and every entry point - the rAF +/// tick, the synchronous JS drag callbacks, and the awaited animate APIs - is expected to run on +/// the Blazor WebAssembly UI thread. Completion continuations are scheduled on +/// , which on single-threaded WASM still runs on that same +/// thread. Do not enable WebAssembly multithreading (<WasmEnableThreads>) with +/// this library: the engine has no synchronization and concurrent access would corrupt its state. +/// +/// public sealed class BmotionAnimationEngine : IAsyncDisposable { private readonly BmotionInterop _interop; + private readonly ILogger? _logger; private readonly Dictionary _elements = new(); private DotNetObjectReference? _dotnet; private bool _loopRunning; @@ -22,7 +36,11 @@ public sealed class BmotionAnimationEngine : IAsyncDisposable // Marshaled synchronously to JS before the next ComputeFrame runs (single-threaded Blazor WASM). private readonly Dictionary> _frameResult = new(); - public BmotionAnimationEngine(BmotionInterop interop) => _interop = interop; + public BmotionAnimationEngine(BmotionInterop interop, ILogger? logger = null) + { + _interop = interop; + _logger = logger; + } // ═══════════════════════════════════════════════════════════════════════════ // Reduced-motion (accessibility) @@ -101,15 +119,24 @@ public void UnregisterElement(string elementId) // Animation control // ═══════════════════════════════════════════════════════════════════════════ - /// Start animating to the given values. Returns immediately (fire-and-forget). + /// + /// Start animating to the given values. Returns immediately (fire-and-forget). + /// + /// Set to true only for the element's resting target + /// (the Animate/variant state a gesture layer should revert to). One-off programmatic + /// animations and drag snap-backs leave it false so they don't clobber the gesture base + /// (which would strand unrelated animated properties when a gesture later deactivates). + /// + /// public async ValueTask AnimateToAsync( string elementId, Dictionary values, BmotionTransitionConfig? transition, - Func? onComplete = null) + Func? onComplete = null, + bool setAsBase = false) { if (!_elements.TryGetValue(elementId, out var state)) return; - state.SetBaseAnimation(values, transition); + if (setAsBase) state.SetBaseAnimation(values, transition); if (onComplete != null) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -135,11 +162,12 @@ public async ValueTask AnimateToAsync( public async ValueTask AnimateToAwaitAsync( string elementId, Dictionary values, - BmotionTransitionConfig? transition) + BmotionTransitionConfig? transition, + bool setAsBase = false) { if (!_elements.TryGetValue(elementId, out var state)) return; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - state.SetBaseAnimation(values, transition); + if (setAsBase) state.SetBaseAnimation(values, transition); state.AnimateTo(values, transition, tcs); await EnsureLoopRunningAsync(); await tcs.Task; @@ -262,12 +290,17 @@ public async ValueTask EndDragAsync( bool inertiaXStarted = false, inertiaYStarted = false; if (momentum) { + // velX/velY arrive from JS already scaled to "px per frame" (~16 ms). The * 50 factor + // converts that frame-relative figure into the larger projected-distance velocity the + // exponential-decay inertia driver expects (tuned so a natural flick throws roughly the + // distance Framer Motion produces for the same gesture). + const double inertiaVelocityScale = 50.0; if (axis != "y" && Math.Abs(velX) > 0.5) { var inertiaX = new BmotionTransitionConfig { Type = BmotionTransitionType.Inertia, - InertiaVelocity = velX * 50, + InertiaVelocity = velX * inertiaVelocityScale, InertiaMin = constraints?.Left, InertiaMax = constraints?.Right, }; @@ -281,7 +314,7 @@ public async ValueTask EndDragAsync( var inertiaY = new BmotionTransitionConfig { Type = BmotionTransitionType.Inertia, - InertiaVelocity = velY * 50, + InertiaVelocity = velY * inertiaVelocityScale, InertiaMin = constraints?.Top, InertiaMax = constraints?.Bottom, }; @@ -386,6 +419,12 @@ public async ValueTask EndDragAsync( { try { badState.CancelAll(); } catch { /* best-effort cleanup */ } _elements.Remove(id); + // Surface the eviction: the owning component still believes it's registered, so + // without this signal a faulted element would silently stop animating forever. + // Bmotion re-registers on its next parameter update (see IsRegistered check). + _logger?.LogWarning( + "Bmotion evicted element '{ElementId}' after its animation tick threw. " + + "Animations on it are stopped until it re-registers.", id); } } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index ff92793f88..b0af0475eb 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -53,7 +53,9 @@ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig co ? (double[])config.Times.Clone() : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); - // Per-segment easing: if ease is an array of length n-1, use one per segment; otherwise use same for all + // Per-segment easing array. Per-segment easing isn't exposed on the transition config yet, + // so every segment currently shares the single configured easing function; the array shape + // is kept so adding per-segment curves later doesn't change the interpolation code path. _eases = new Func[n - 1]; var globalEase = BmotionEasingFunctions.Get(config); for (int i = 0; i < n - 1; i++) diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs index 33e4f8162d..0ecc111134 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs @@ -51,8 +51,13 @@ public BmotionSpringDriver(double from, double to, BmotionTransitionConfig confi // trap the spring (the rest test would never pass). Fall back to the default mass of 1. _m = config.Mass > 0 ? config.Mass : 1.0; _vel = _initialVel = config.Velocity; - _restSpeed = config.RestSpeed; - _restDelta = config.RestDelta; + // Rest thresholds are scaled by the animation's magnitude so large-range springs (e.g. + // x: 0→1000) settle in proportion to their distance instead of chasing an absolute 0.01px/ + // 0.01px-per-sec target for many extra frames. Small ranges keep the absolute thresholds. + double range = Math.Abs(to - from); + double restScale = range > 1.0 ? range : 1.0; + _restSpeed = config.RestSpeed * restScale; + _restDelta = config.RestDelta * restScale; _currentDelayMs = config.Delay * 1000; _repeatDelayMs = config.RepeatDelay * 1000; _repeat = config.Repeat; diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs index 36eaab4f27..415a32c053 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs @@ -4,6 +4,12 @@ namespace Bit.Bmotion; /// Describes a set of animatable CSS / transform properties - the "what" of an animation. /// Assign to Initial, Animate, Exit, WhileHover, WhileTap, etc. /// +/// +/// Security: string-valued properties (, , +/// , , …) are written verbatim into the element's +/// inline style. They are intended for developer-authored values; binding untrusted end-user input +/// to them risks CSS injection into the element's style. +/// public class BmotionAnimationProps { // ── Transform properties ────────────────────────────────────────────────── diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs index d5f428d7d9..8a148f854b 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -34,6 +34,13 @@ public double[]? EaseCubicBezier throw new ArgumentException( "EaseCubicBezier must be null or an array of exactly 4 finite values [x1, y1, x2, y2].", nameof(value)); + // The control-point X coordinates must stay within [0, 1] so the bezier's x(t) curve is + // monotonic. Outside that range x(t) can fold back on itself, and the Newton-Raphson solver + // in BmotionEasingFunctions can then converge to the wrong root (a visibly broken easing). + if (value[0] is < 0 or > 1 || value[2] is < 0 or > 1) + throw new ArgumentException( + "EaseCubicBezier X coordinates (x1, x2) must be within [0, 1]; only Y may overshoot.", + nameof(value)); return value; } diff --git a/src/Bmotion/Bit.Bmotion/README.md b/src/Bmotion/Bit.Bmotion/README.md index 87fc118777..db38c850f8 100644 --- a/src/Bmotion/Bit.Bmotion/README.md +++ b/src/Bmotion/Bit.Bmotion/README.md @@ -30,3 +30,38 @@ builder.Services.AddBitBmotionServices(); See the XML documentation on `Bmotion`, `BmotionAnimateService`, and `BmotionTransitionConfig` for the full API surface. + +## Accessibility: reduced motion is opt-in + +`prefers-reduced-motion` is **only** honoured for elements inside a ``. Elements +with no surrounding config always animate, even when the OS requests reduced motion. This is a +deliberate choice (so the OS setting never silently disables animations an app didn't opt into), +but it is the opposite of the web-platform default — if you care about reduced-motion +accessibility, wrap your app (or the relevant subtree) in a config: + +```razor + @* ReduceMotion defaults to null = follow the OS setting *@ + ... + +``` + +Set `ReduceMotion="true"`/`"false"` to force it on or off regardless of the OS preference. + +## Security note + +String-valued animation properties (`BackgroundColor`, `Width`, `BoxShadow`, `Color`, custom +`CssVars`, …) are written verbatim into the element's inline style. They are meant for +developer-authored values. Do **not** bind untrusted end-user input to them, or you risk CSS +injection into the element's `style`. + +## Threading + +The animation engine is intentionally lock-free and assumes a single (the WebAssembly UI) thread. +Do **not** enable WebAssembly multithreading (``) with this library. + +## Disposing the programmatic helpers + +`BmotionAnimationController` and `BmotionScrollTracker` are registered `Transient`. When obtained +via `@inject`, Blazor does **not** dispose them per component — the consuming component must +dispose them itself (from its own `Dispose`/`DisposeAsync`), otherwise their engine registration / +JS subscription leaks until the app shuts down. diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs index 2349a1720d..3c5d67b3f1 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs @@ -97,9 +97,11 @@ public BmotionValue Transform(Func fn) where TOut : struct { ArgumentNullException.ThrowIfNull(fn); var derived = new BmotionValue($"{_id}_t", fn(_value)); - // Keep the parent→derived link so it can be torn down when the derived value is disposed, - // otherwise the parent would hold the derived value alive indefinitely (a leak). - derived._upstream = Subscribe(async v => await derived.SetAsync(fn(v))); + // Subscribe weakly: the parent must not keep the derived value alive (that would make the + // parent→derived link a leak for callers that drop the derived). The derived keeps the + // parent alive via _upstream for as long as the caller holds the derived, which is the + // intended direction. The subscription self-removes once the derived is collected. + derived._upstream = SubscribeWeak(derived, fn); return derived; } @@ -136,10 +138,30 @@ double Map(T v) } var derived = new BmotionValue($"{_id}_tr", Map(_value)); - derived._upstream = Subscribe(async v => await derived.SetAsync(Map(v))); + derived._upstream = SubscribeWeak(derived, Map); return derived; } + /// + /// Subscribes the value to this value's changes through a weak + /// reference, so this (parent) value never keeps the derived one alive. The subscription + /// removes itself the first time it fires after the derived value has been collected. + /// + private IDisposable SubscribeWeak(BmotionValue derived, Func project) + where TOut : struct + { + var weak = new WeakReference>(derived); + IDisposable? sub = null; + sub = Subscribe(async v => + { + if (weak.TryGetTarget(out var target)) + await target.SetAsync(project(v)); + else + sub?.Dispose(); // derived value collected - drop the dead subscription + }); + return sub; + } + public void Dispose() { _upstream?.Dispose(); diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js index 5b5b1d25f0..e5fe0a4d55 100644 --- a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js +++ b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js @@ -259,7 +259,7 @@ function _attachPan(el, dotnetRef, cleanups, skipCapture) { if (e.button !== 0 && e.pointerType !== 'touch') return; down = true; startX = lastX = e.clientX; startY = lastY = e.clientY; - lastT = Date.now(); velX = velY = 0; panning = false; + lastT = performance.now(); velX = velY = 0; panning = false; // Skip when drag already owns the pointer capture for this element. if (!skipCapture) el.setPointerCapture(e.pointerId); }; @@ -269,7 +269,7 @@ function _attachPan(el, dotnetRef, cleanups, skipCapture) { // coordinates from a previous gesture can't trigger a phantom pan. if (!down) return; const dx = e.clientX - startX, dy = e.clientY - startY; - const now = Date.now(), dt = now - lastT; + const now = performance.now(), dt = now - lastT; const deltaX = e.clientX - lastX, deltaY = e.clientY - lastY; if (dt > 0) { velX = deltaX / dt * 1000; @@ -330,7 +330,7 @@ function _attachDrag(elementId, el, opts, dotnetRef, cleanups) { startElX = pos ? pos.x : 0; startElY = pos ? pos.y : 0; startPX = e.clientX; startPY = e.clientY; - lastPX = e.clientX; lastPY = e.clientY; lastT = Date.now(); + lastPX = e.clientX; lastPY = e.clientY; lastT = performance.now(); velX = velY = 0; dragging = true; lockedAxis = null; @@ -340,7 +340,7 @@ function _attachDrag(elementId, el, opts, dotnetRef, cleanups) { const onMove = (e) => { if (!dragging) return; - const now = Date.now(), dt = now - lastT; + const now = performance.now(), dt = now - lastT; if (dt > 0) { velX = (e.clientX - lastPX) / dt * FRAME_MS; velY = (e.clientY - lastPY) / dt * FRAME_MS; } lastPX = e.clientX; lastPY = e.clientY; lastT = now; From dc758999d859051e7c0e4fa0e3b557f7c747070a Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 09:35:29 +0330 Subject: [PATCH 13/24] resolve review comments VI --- .../Bit.Bmotion.Demos/Pages/Springs.razor | 2 + .../Components/BmotionConfig.razor | 17 +++++--- .../Engine/BmotionAnimationEngine.cs | 42 +++++++++++++------ .../Engine/BmotionColorInterpolator.cs | 12 +++--- .../Engine/BmotionNumericKeyframesDriver.cs | 2 +- .../Models/BmotionAnimationProps.cs | 35 ++++++++++++---- .../Models/BmotionTransitionConfig.cs | 8 +++- 7 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor index 33bdddf37d..9214baf063 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor @@ -1,5 +1,7 @@ @page "/springs" +

Springs

+

Spring Physics

diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor index de24ccdbae..d64eae35e6 100644 --- a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor @@ -26,14 +26,19 @@ /// [Parameter] public double TransitionSpeed { get; set; } = 1.0; - private readonly BmotionConfigContext _ctx = new(); + private BmotionConfigContext _ctx = new(); protected override void OnParametersSet() { - _ctx.DefaultTransition = Transition; - _ctx.ReduceMotion = ReduceMotion; - // The context setter coerces negative / non-finite (NaN/Infinity) speeds to 0, so a bad - // binding can't corrupt duration math or crash the render. - _ctx.TransitionSpeed = TransitionSpeed; + // Replace the context with a fresh instance (rather than mutating in place) so the cascading + // value's reference changes whenever parameters change, ensuring descendants are notified. + // The TransitionSpeed setter still coerces negative / non-finite (NaN/Infinity) speeds to 0, + // so a bad binding can't corrupt duration math or crash the render. + _ctx = new BmotionConfigContext + { + DefaultTransition = Transition, + ReduceMotion = ReduceMotion, + TransitionSpeed = TransitionSpeed, + }; } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index 2abf044d6e..064a49517f 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -30,6 +30,7 @@ public sealed class BmotionAnimationEngine : IAsyncDisposable private readonly Dictionary _elements = new(); private DotNetObjectReference? _dotnet; private bool _loopRunning; + private readonly SemaphoreSlim _loopStartGate = new(1, 1); private bool _reducedMotionDetected; // Reused across frames so the rAF tick doesn't allocate a fresh outer dictionary every ~16 ms. @@ -443,19 +444,33 @@ public async ValueTask EnsureLoopRunningAsync() { if (_loopRunning) return; - // Bit.Bmotion's animation loop relies on synchronous JS→.NET interop (the JS rAF ticker - // calls ComputeFrame synchronously). That is only available on Blazor WebAssembly; on - // Blazor Server / SSR the call would throw an opaque error, so fail fast with a clear one. - if (!_interop.IsInProcess) - throw new PlatformNotSupportedException( - "Bit.Bmotion requires synchronous JS interop and is only supported on Blazor WebAssembly. " + - "It cannot run on Blazor Server or during server-side prerendering."); - - _dotnet ??= DotNetObjectReference.Create(this); - await _interop.StartRafLoopAsync(_dotnet); - // Only flag the loop as running once startup actually succeeded; if the interop call - // throws, the flag stays false so a later call can retry instead of silently no-op'ing. - _loopRunning = true; + // Concurrent callers (e.g. several elements registering on the same frame) can all pass the + // check above before any of them flips _loopRunning, which would start the rAF loop more + // than once. Serialize startup behind a gate so only the first caller starts it; the rest + // re-check _loopRunning after acquiring the gate and become no-ops. + await _loopStartGate.WaitAsync(); + try + { + if (_loopRunning) return; + + // Bit.Bmotion's animation loop relies on synchronous JS→.NET interop (the JS rAF ticker + // calls ComputeFrame synchronously). That is only available on Blazor WebAssembly; on + // Blazor Server / SSR the call would throw an opaque error, so fail fast with a clear one. + if (!_interop.IsInProcess) + throw new PlatformNotSupportedException( + "Bit.Bmotion requires synchronous JS interop and is only supported on Blazor WebAssembly. " + + "It cannot run on Blazor Server or during server-side prerendering."); + + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.StartRafLoopAsync(_dotnet); + // Only flag the loop as running once startup actually succeeded; if the interop call + // throws, the flag stays false so a later call can retry instead of silently no-op'ing. + _loopRunning = true; + } + finally + { + _loopStartGate.Release(); + } } private void StopLoopInternal() @@ -500,6 +515,7 @@ public async ValueTask DisposeAsync() _dotnet.Dispose(); _dotnet = null; } + _loopStartGate.Dispose(); // BmotionInterop is owned and disposed by the DI container (it is registered scoped), // so the engine must not dispose it here or it would be disposed twice. } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs index 5ff7f8cb04..139e98ba62 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs @@ -8,12 +8,12 @@ internal static partial class BmotionColorInterpolator // Source-generated regexes: faster than the static Regex cache and trim-safe (no runtime // IL emit), which matters because this assembly is built with IsTrimmable=true. [System.Text.RegularExpressions.GeneratedRegex( - @"rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)", + @"^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex RgbRegex(); [System.Text.RegularExpressions.GeneratedRegex( - @"hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)", + @"^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)] private static partial System.Text.RegularExpressions.Regex HslRegex(); ///

@@ -40,10 +40,10 @@ public static string Lerp(string from, string to, double t) /// public static string Lerp(double[] from, double[] to, double t) { - int r = (int)Math.Round(from[0] + (to[0] - from[0]) * t); - int g = (int)Math.Round(from[1] + (to[1] - from[1]) * t); - int b = (int)Math.Round(from[2] + (to[2] - from[2]) * t); - double a = from[3] + (to[3] - from[3]) * t; + int r = (int)Math.Round(Math.Clamp(from[0] + (to[0] - from[0]) * t, 0, 255)); + int g = (int)Math.Round(Math.Clamp(from[1] + (to[1] - from[1]) * t, 0, 255)); + int b = (int)Math.Round(Math.Clamp(from[2] + (to[2] - from[2]) * t, 0, 255)); + double a = Math.Clamp(from[3] + (to[3] - from[3]) * t, 0, 1); return $"rgba({r},{g},{b},{BmotionCssFormat.Num(a, "G4")})"; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index b0af0475eb..90c448bf31 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -124,7 +124,7 @@ private static double Interpolate(double[] frames, double[] times, Func 0 ? (t - times[seg]) / segLen : 1.0; - double easedT = eases[seg](Math.Min(segT, 1.0)); + double easedT = eases[seg](Math.Clamp(segT, 0.0, 1.0)); return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT; } } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs index 415a32c053..f9b1c8f714 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs @@ -148,11 +148,19 @@ internal string ToCssStyleString() else transforms.Add($"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); } - if (Scale.HasValue) transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); - if (ScaleX.HasValue) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); - if (ScaleY.HasValue) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); - if (Rotate.HasValue || RotateZ.HasValue) - transforms.Add($"rotate({BmotionCssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); + if (Scale.HasValue && Scale.Value != 1) + { + transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + } + else + { + if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); + } + // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0 + // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). + double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); + if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); @@ -211,10 +219,19 @@ internal Dictionary ToCssStyleDictionary() ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)" : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); } - if (Scale.HasValue) transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); - if (ScaleX.HasValue) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); - if (ScaleY.HasValue) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); - if (Rotate.HasValue || RotateZ.HasValue) transforms.Add($"rotate({BmotionCssFormat.Num(RotateZ ?? Rotate ?? 0)}deg)"); + if (Scale.HasValue && Scale.Value != 1) + { + transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + } + else + { + if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); + } + // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0 + // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). + double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); + if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs index 8a148f854b..4417289a68 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -22,8 +22,12 @@ public class BmotionTransitionConfig /// public double[]? EaseCubicBezier { - get => _easeCubicBezier; - set => _easeCubicBezier = ValidateCubicBezier(value); + get => _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone(); + set + { + var validated = ValidateCubicBezier(value); + _easeCubicBezier = validated is null ? null : (double[])validated.Clone(); + } } private double[]? _easeCubicBezier; From 56060869d9b81011dfa0db61c01644007f09f7b5 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 10:29:00 +0330 Subject: [PATCH 14/24] resolve review comments VII --- .../Bit.Bmotion.Demos/Pages/Variants.razor | 2 ++ .../Engine/BmotionAnimationEngine.cs | 28 +++++++++++++++++-- .../Engine/BmotionElementAnimationState.cs | 11 ++++++++ .../Engine/BmotionNumericKeyframesDriver.cs | 5 ++++ .../Bit.Bmotion/Models/BmotionRepeatType.cs | 11 +++++++- .../Bit.Bmotion/Models/BmotionScrollInfo.cs | 7 +++++ .../Models/BmotionTransitionConfig.cs | 4 ++- .../Models/BmotionTransitionType.cs | 13 ++++++++- .../Models/BmotionViewportOptions.cs | 2 +- .../Services/BmotionAnimationController.cs | 9 ++++-- .../Engine/TransformComposerTests.cs | 19 +++++++++++++ .../Engine/TweenDriverTests.cs | 20 +++++++++++++ .../Models/TransitionConfigTests.cs | 24 ++++++++++++++++ 13 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor index f68d037b31..a20896aa1b 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor @@ -1,5 +1,7 @@ @page "/variants" +

Variants

+

Variants

diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index 064a49517f..cd4591603f 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -476,10 +476,32 @@ public async ValueTask EnsureLoopRunningAsync() private void StopLoopInternal() { if (!_loopRunning) return; + // Clear the flag synchronously so a re-entrant ComputeFrame this frame doesn't schedule a + // second stop, but defer the actual JS teardown behind _loopStartGate (the same gate + // EnsureLoopRunningAsync uses to start the loop). Serializing stop and start prevents a + // delayed stop from racing a restart - tearing down a freshly started loop while + // _loopRunning is true would otherwise leave the engine stuck (flagged running, JS stopped). _loopRunning = false; - // Pass our own engine ref so only this engine is removed from the shared JS loop, - // leaving any other Blazor-root engines ticking. - _ = _interop.StopRafLoopAsync(_dotnet); + _ = StopRafLoopGatedAsync(); + + async Task StopRafLoopGatedAsync() + { + await _loopStartGate.WaitAsync(); + try + { + // A restart may have re-flipped _loopRunning to true after we cleared it (and + // already (re)started the JS loop). Skip the stale stop so we don't tear it down. + if (_loopRunning) return; + // Pass our own engine ref so only this engine is removed from the shared JS loop, + // leaving any other Blazor-root engines ticking. + await _interop.StopRafLoopAsync(_dotnet); + } + catch { /* mid-session stop is best-effort; teardown is ordered explicitly in DisposeAsync */ } + finally + { + _loopStartGate.Release(); + } + } } ///

diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs index b45348e22d..91787da1b9 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -273,6 +273,7 @@ public void AnimateTo( // Non-colour string keyframes (e.g. dimension arrays) have no interpolating driver; // snap to the final frame so the value still lands on its destination. StringValues[key] = otherFrames[^1]; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive _dirtyProps.Add(key); } else if (TryConvertToDouble(value, out double numeric)) @@ -315,16 +316,19 @@ public void SetInstant(Dictionary values) else if (IsColorProp(key) && value is string colorStr) { StringValues[key] = colorStr; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive _dirtyProps.Add(key); } else if (value is string dimStr) { StringValues[key] = dimStr; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive _dirtyProps.Add(key); } else if (TryConvertToDouble(value, out double nv)) { NumericValues[key] = nv; + StringValues.Remove(key); // keep numeric/string stores mutually exclusive _dirtyProps.Add(key); } } @@ -566,12 +570,18 @@ private void ApplyTransform(string key, double value) private void ApplyNumeric(string key, double value) { NumericValues[key] = value; + // Keep the numeric/string stores mutually exclusive: Tick emits a prop from NumericValues + // first, so a stale string entry for the same key would otherwise be masked (and vice versa). + StringValues.Remove(key); _dirtyProps.Add(key); } private void ApplyString(string key, string value) { StringValues[key] = value; + // Mutually exclusive with NumericValues (see ApplyNumeric): a stale numeric entry for this + // key would mask the string update during Tick emission. + NumericValues.Remove(key); _dirtyProps.Add(key); } @@ -643,6 +653,7 @@ private void CreateCssDimensionDriver(string key, string toValue, BmotionTransit { // Snap and mark dirty - no interpolation possible across different units. StringValues[key] = toValue; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive _dirtyProps.Add(key); } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs index 90c448bf31..0954ddb1fb 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -22,6 +22,11 @@ public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig co { if (frames is null || frames.Length < 2) throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames)); + if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay)) + // NaN/infinite timing values poison _startTime in the progress math (e.g. a NaN delay + // makes _startTime NaN), pushing invalid values through _apply. Reject them up front. + throw new ArgumentException( + "Duration, Delay and RepeatDelay must be finite values.", nameof(config)); if (config.Times != null && config.Times.Length != frames.Length) throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); if (config.Times != null) diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs index cb3396da9f..edbb15638b 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs @@ -1,3 +1,12 @@ namespace Bit.Bmotion; -public enum BmotionRepeatType { Loop, Mirror, Reverse } +/// How a repeating animation behaves on each subsequent iteration. +public enum BmotionRepeatType +{ + /// Restart from the beginning each iteration (0 → 1, 0 → 1, …). + Loop, + /// Ping-pong: alternate direction each iteration (0 → 1, 1 → 0, …). + Mirror, + /// Jump back to the start and play in reverse each iteration (1 → 0, 1 → 0, …). + Reverse, +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs index cd33abb69e..4d3dff38c1 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs @@ -15,8 +15,15 @@ public class BmotionScrollInfo /// Vertical scroll progress 0–1. public double ProgressY { get; init; } + /// Total scrollable content width in pixels (including the part outside the viewport). public double ScrollWidth { get; init; } + + /// Total scrollable content height in pixels (including the part outside the viewport). public double ScrollHeight { get; init; } + + /// Visible viewport width in pixels (the currently displayed area). public double ClientWidth { get; init; } + + /// Visible viewport height in pixels (the currently displayed area). public double ClientHeight { get; init; } } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs index 4417289a68..646d3845e4 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -165,7 +165,9 @@ public double[]? EaseCubicBezier Duration = Duration, Delay = Delay, Ease = Ease, - EaseCubicBezier = EaseCubicBezier is null ? null : (double[])EaseCubicBezier.Clone(), + // Read the backing field directly: the EaseCubicBezier getter already returns a defensive + // clone, so cloning it again here would allocate a redundant second copy. + EaseCubicBezier = _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone(), Repeat = Repeat, RepeatInfinite = RepeatInfinite, RepeatType = RepeatType, diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs index 9122b72b8b..3492bcf741 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs @@ -1,3 +1,14 @@ namespace Bit.Bmotion; -public enum BmotionTransitionType { Tween, Spring, Inertia, Keyframes } +/// The animation driver (physics or interpolation model) used for a transition. +public enum BmotionTransitionType +{ + /// Duration- and easing-based interpolation between start and end values. + Tween, + /// Physics-based spring driven by stiffness, damping and mass. + Spring, + /// Velocity-based deceleration that coasts to a stop (e.g. momentum after a drag). + Inertia, + /// Interpolation across an array of keyframe values rather than a single target. + Keyframes, +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs index 107f770909..f55348b7de 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs @@ -35,7 +35,7 @@ internal object ToJsObject() { "some" => 0.0, "all" => 1.0, - _ => double.TryParse(amount, System.Globalization.NumberStyles.Any, + _ => double.TryParse(amount, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) && double.IsFinite(v) ? Math.Clamp(v, 0, 1) : 0.0, }; diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs index 7ef3e5a95d..9ceffb9447 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs @@ -40,21 +40,24 @@ public void BindTo(string elementId) /// Animate the bound element to the given props (fire-and-forget). public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { - if (_elementId == null || props == null) return; + ArgumentNullException.ThrowIfNull(props); + if (_elementId == null) return; await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition); } /// Animate and await completion. public async ValueTask AnimateAwaitAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) { - if (_elementId == null || props == null) return; + ArgumentNullException.ThrowIfNull(props); + if (_elementId == null) return; await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition); } /// Instantly set props without animation. public void Set(BmotionAnimationProps props) { - if (_elementId == null || props == null) return; + ArgumentNullException.ThrowIfNull(props); + if (_elementId == null) return; _engine.SetInstant(_elementId, props.ToJsDictionary()); } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TransformComposerTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TransformComposerTests.cs index f949f6fa22..14be4bb244 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TransformComposerTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TransformComposerTests.cs @@ -31,6 +31,25 @@ public void IsTransformProp_ReturnsExpected(string key, bool expected) Assert.AreEqual(expected, BmotionTransformComposer.IsTransformProp(key)); } + // ── Build - case-insensitive keys ───────────────────────────────────────── + + [TestMethod] + public void Build_MixedCaseKeys_ComposesTransform() + { + // The engine stores transform components in a case-insensitive dictionary (matching + // IsTransformProp's OrdinalIgnoreCase contract), so mixed-case keys identified as valid + // transform props must still compose end-to-end rather than being silently dropped. + var t = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X"] = 10, + ["SCALE"] = 2.0, + }; + var result = BmotionTransformComposer.Build(t); + + StringAssert.Contains(result, "translate(10px,0px)"); + StringAssert.Contains(result, "scale(2)"); + } + // ── Build - empty/identity ──────────────────────────────────────────────── [TestMethod] diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs index d8811b8ee5..b2a5f68da0 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Engine/TweenDriverTests.cs @@ -169,4 +169,24 @@ public void Tick_InfiniteRepeat_NeverReturnsDone() Assert.IsFalse(done, $"Unexpected completion after iteration {i}"); } } + + [TestMethod] + public void Tick_RepeatInfiniteFlag_NeverReturnsDone() + { + // Exercises the preferred RepeatInfinite flag directly rather than the legacy + // Repeat = int.MaxValue sentinel covered above. + var log = new List(); + var driver = Create(0, 100, new BmotionTransitionConfig + { + Duration = 0.3, + RepeatInfinite = true, + }, log); + + driver.Tick(0); + for (int i = 1; i <= 10; i++) + { + bool done = driver.Tick(i * 300.0); + Assert.IsFalse(done, $"Unexpected completion after iteration {i}"); + } + } } diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs index 1d610df08d..6adc4bc642 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs @@ -165,6 +165,30 @@ public void EaseCubicBezier_CanBeSet() Assert.AreEqual(0.25, config.EaseCubicBezier[0]); } + [TestMethod] + public void EaseCubicBezier_WrongLength_Throws() + { + Assert.ThrowsExactly(() => + _ = new BmotionTransitionConfig { EaseCubicBezier = [0.25, 0.1, 0.25] }); + } + + [TestMethod] + public void EaseCubicBezier_NonFiniteValue_Throws() + { + Assert.ThrowsExactly(() => + _ = new BmotionTransitionConfig { EaseCubicBezier = [0.25, 0.1, double.NaN, 1.0] }); + } + + [TestMethod] + public void EaseCubicBezier_XControlPointOutOfRange_Throws() + { + // x1 (index 0) and x2 (index 2) must stay within [0, 1]; only Y may overshoot. + Assert.ThrowsExactly(() => + _ = new BmotionTransitionConfig { EaseCubicBezier = [1.5, 0.1, 0.25, 1.0] }); + Assert.ThrowsExactly(() => + _ = new BmotionTransitionConfig { EaseCubicBezier = [0.25, 0.1, -0.2, 1.0] }); + } + // ── Clone ───────────────────────────────────────────────────────────────── [TestMethod] From 7df7f9537e9d10f0c4b1a47015e896419bcefe71 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 12:59:31 +0330 Subject: [PATCH 15/24] resolve review comments VIII --- src/Bmotion/Bit.Bmotion.Demos/App.razor | 1 + .../Bit.Bmotion.Demos/Layout/MainLayout.razor | 4 +-- .../Bit.Bmotion.Demos/Pages/Keyframes.razor | 2 ++ .../Bit.Bmotion.Demos/Pages/LayoutPage.razor | 2 ++ .../Pages/ScrollAnimations.razor | 6 ++-- .../Engine/BmotionAnimationEngine.cs | 21 +++++++++---- .../Engine/BmotionColorInterpolator.cs | 30 +++++++++++-------- .../Engine/BmotionColorKeyframesDriver.cs | 17 +++++++++++ .../Models/BmotionAnimationProps.cs | 25 ++++++++++------ .../Services/BmotionAnimationControls.cs | 5 ++++ .../Services/BmotionScrollTracker.cs | 20 +++++++++---- .../Bit.Bmotion/Services/BmotionValue.cs | 15 ++++++---- .../Models/TransitionConfigTests.cs | 4 ++- 13 files changed, 109 insertions(+), 43 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion.Demos/App.razor b/src/Bmotion/Bit.Bmotion.Demos/App.razor index 36912bb8ae..716538fff3 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/App.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/App.razor @@ -6,6 +6,7 @@ Not found +

Not found

Sorry, there's nothing at this address.

diff --git a/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor b/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor index 812208ff0c..8e37f658f3 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor @@ -1,6 +1,6 @@ @inherits LayoutComponentBase -
+
+
@Body diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor index 63be2f00ed..5e40695e19 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor @@ -1,5 +1,7 @@ @page "/keyframes" +

Keyframes

+

Keyframes

diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor index c60d8a745f..01aa104008 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor @@ -1,5 +1,7 @@ @page "/layout" +

Layout Animations

+

Layout Animations (FLIP)

diff --git a/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor index 092bd7c7cb..e6eb0e0e47 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor @@ -2,13 +2,15 @@ @inject BmotionScrollTracker Scroll @implements IAsyncDisposable +

Scroll Animations

+
Window scroll progress
- Y: @((_progressY * 100).ToString("F1"))% | X: @((_progressX * 100).ToString("F1"))% + Y: @((_progressY * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% | X: @((_progressX * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%
@@ -86,7 +88,7 @@
- Y: @((_progressY * 100).ToString("F1"))% | X: @((_progressX * 100).ToString("F1"))% + Y: @((_progressY * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% | X: @((_progressX * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%
diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs index cd4591603f..8710cfe772 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -63,12 +63,8 @@ public async ValueTask EnsureReducedMotionDetectedAsync() try { OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync(); - // Subscribe to live OS changes so toggling prefers-reduced-motion at runtime is honoured - // (the value was previously cached for the engine's whole lifetime). - _dotnet ??= DotNetObjectReference.Create(this); - await _interop.WatchReducedMotionAsync(_dotnet); - // Only mark detection complete once both interop calls succeed, so a transient - // failure leaves the flag unset and a later call can retry. + // Mark detection complete as soon as the initial probe succeeds: the probed value is + // valid regardless of whether the live-change subscription below can be set up. _reducedMotionDetected = true; } catch @@ -76,6 +72,19 @@ public async ValueTask EnsureReducedMotionDetectedAsync() // Detection is best-effort: if the browser probe fails we default to // animating normally rather than letting it break element initialisation. OsPrefersReducedMotion = false; + return; + } + + try + { + // Subscribe to live OS changes so toggling prefers-reduced-motion at runtime is honoured. + // Best-effort: a watch-setup failure must not discard the successfully probed value above. + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.WatchReducedMotionAsync(_dotnet); + } + catch + { + // Watch subscription failed; the initial preference stays valid and detection stays complete. } } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs index 139e98ba62..f74f21bb01 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs @@ -89,24 +89,30 @@ public static bool LooksLikeColor(string? value) var m = RgbRegex().Match(c); if (m.Success) { - return - [ - BmotionCssFormat.Parse(m.Groups[1].Value), - BmotionCssFormat.Parse(m.Groups[2].Value), - BmotionCssFormat.Parse(m.Groups[3].Value), - m.Groups[4].Success ? BmotionCssFormat.Parse(m.Groups[4].Value) : 1.0, - ]; + // Use TryParse so malformed numerics like "1..2" fall back to null (and the Lerp + // string overload then returns the target) instead of throwing a FormatException. + if (!BmotionCssFormat.TryParse(m.Groups[1].Value, out double mr) || + !BmotionCssFormat.TryParse(m.Groups[2].Value, out double mg) || + !BmotionCssFormat.TryParse(m.Groups[3].Value, out double mb)) + return null; + double ma = 1.0; + if (m.Groups[4].Success && !BmotionCssFormat.TryParse(m.Groups[4].Value, out ma)) + return null; + return [mr, mg, mb, ma]; } // hsl() / hsla() var mh = HslRegex().Match(c); if (mh.Success) { - double h2 = BmotionCssFormat.Parse(mh.Groups[1].Value); - double s2 = BmotionCssFormat.Parse(mh.Groups[2].Value) / 100.0; - double l2 = BmotionCssFormat.Parse(mh.Groups[3].Value) / 100.0; - double a2 = mh.Groups[4].Success ? BmotionCssFormat.Parse(mh.Groups[4].Value) : 1.0; - var rgb2 = HslToRgb(h2, s2, l2); + if (!BmotionCssFormat.TryParse(mh.Groups[1].Value, out double h2) || + !BmotionCssFormat.TryParse(mh.Groups[2].Value, out double s2raw) || + !BmotionCssFormat.TryParse(mh.Groups[3].Value, out double l2raw)) + return null; + double a2 = 1.0; + if (mh.Groups[4].Success && !BmotionCssFormat.TryParse(mh.Groups[4].Value, out a2)) + return null; + var rgb2 = HslToRgb(h2, s2raw / 100.0, l2raw / 100.0); return [rgb2[0], rgb2[1], rgb2[2], a2]; } diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs index 10ed613687..e945509b3d 100644 --- a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs @@ -23,8 +23,25 @@ public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig conf { if (frames is null || frames.Length < 2) throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames)); + if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay)) + // NaN/infinite timing values poison _startTime in the progress math, pushing invalid + // values through _apply. Reject them up front (matches the numeric keyframes driver). + throw new ArgumentException( + "Duration, Delay and RepeatDelay must be finite values.", nameof(config)); if (config.Times != null && config.Times.Length != frames.Length) throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); + if (config.Times != null) + { + // Times feed the segment math; non-monotonic or out-of-range values produce + // negative/zero segment lengths and NaN output, so reject them up front. + for (int i = 0; i < config.Times.Length; i++) + { + if (config.Times[i] < 0 || config.Times[i] > 1) + throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config)); + if (i > 0 && config.Times[i] < config.Times[i - 1]) + throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config)); + } + } _frames = (string[])frames.Clone(); _curFrames = (string[])frames.Clone(); diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs index f9b1c8f714..5c9bb4cda6 100644 --- a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs @@ -49,7 +49,14 @@ public class BmotionAnimationProps public string? BoxShadow { get; set; } // ── SVG path drawing ────────────────────────────────────────────────────── - /// 0 = invisible, 1 = fully drawn. Drives strokeDashoffset. + /// + /// 0 = invisible, 1 = fully drawn. Drives strokeDashoffset. + /// + /// The generated stroke-dasharray/stroke-dashoffset values are normalised to a + /// unit path length, so the target SVG element must declare pathLength="1" for the + /// drawing to render correctly. + /// + /// public double? PathLength { get; set; } /// Offset along the path (0–1). public double? PathOffset { get; set; } @@ -161,10 +168,10 @@ internal string ToCssStyleString() // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); - if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); - if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); - if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); - if (SkewY.HasValue) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); + if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); if (Perspective.HasValue) transforms.Insert(0, $"perspective({BmotionCssFormat.Num(Perspective.Value)}px)"); if (transforms.Count > 0) sb.Append($"transform:{string.Join(" ", transforms)};"); @@ -232,10 +239,10 @@ internal Dictionary ToCssStyleDictionary() // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); - if (RotateX.HasValue) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); - if (RotateY.HasValue) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); - if (SkewX.HasValue) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); - if (SkewY.HasValue) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); + if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); if (transforms.Count > 0) d["transform"] = string.Join(" ", transforms); if (Opacity.HasValue) d["opacity"] = BmotionCssFormat.Num(Opacity.Value); diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs index 5f61a5eef8..90666ecd11 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs @@ -43,6 +43,9 @@ private void ReleaseOnce() ///
public void Stop() { + // Once released (e.g. a natural finish already settled the completion), the target elements + // may be owned by newer animations - skip engine side effects so we don't disturb them. + if (System.Threading.Volatile.Read(ref _released) != 0) return; foreach (var id in _elementIds) _engine.Stop(id, null); ReleaseOnce(); @@ -53,6 +56,8 @@ public void Stop() /// public void Complete() { + // See Stop(): no engine side effects once released, to avoid affecting newer animations. + if (System.Threading.Volatile.Read(ref _released) != 0) return; foreach (var id in _elementIds) _engine.Complete(id); ReleaseOnce(); diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs index cf92960d7a..50b841bc5b 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -107,11 +107,19 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; - foreach (var key in _subscriptionKeys) - await _interop.UnobserveScrollAsync(key); - _subscriptionKeys.Clear(); - _onScroll = null; - _dotnet?.Dispose(); - // Note: BmotionInterop itself is DI-scoped and disposed by the DI container + try + { + foreach (var key in _subscriptionKeys) + await _interop.UnobserveScrollAsync(key); + } + finally + { + // Always release local resources, even if a JS unobserve call faults during teardown, + // so the DotNetObjectReference and callback don't leak. + _subscriptionKeys.Clear(); + _onScroll = null; + _dotnet?.Dispose(); + // Note: BmotionInterop itself is DI-scoped and disposed by the DI container + } } } diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs index 3c5d67b3f1..2e30baf3a6 100644 --- a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs @@ -123,18 +123,23 @@ public BmotionValue Transform(double[] inputRange, double[] outputRange) if (inputRange[i + 1] <= inputRange[i]) throw new ArgumentException("inputRange must be strictly increasing (no repeated or decreasing points)."); + // Snapshot the ranges so the Map closure isn't affected by the caller mutating the + // passed-in arrays after this method returns (which would bypass the validation above). + var inRange = (double[])inputRange.Clone(); + var outRange = (double[])outputRange.Clone(); + double Map(T v) { double x = Convert.ToDouble(v); - for (int i = 0; i < inputRange.Length - 1; i++) + for (int i = 0; i < inRange.Length - 1; i++) { - if (x >= inputRange[i] && x <= inputRange[i + 1]) + if (x >= inRange[i] && x <= inRange[i + 1]) { - double t = (x - inputRange[i]) / (inputRange[i + 1] - inputRange[i]); - return outputRange[i] + t * (outputRange[i + 1] - outputRange[i]); + double t = (x - inRange[i]) / (inRange[i + 1] - inRange[i]); + return outRange[i] + t * (outRange[i + 1] - outRange[i]); } } - return x < inputRange[0] ? outputRange[0] : outputRange[^1]; + return x < inRange[0] ? outRange[0] : outRange[^1]; } var derived = new BmotionValue($"{_id}_tr", Map(_value)); diff --git a/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs b/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs index 6adc4bc642..b883c3acbd 100644 --- a/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs +++ b/src/Bmotion/Tests/Bit.Bmotion.Tests/Models/TransitionConfigTests.cs @@ -273,7 +273,9 @@ public void Clone_IsIndependent_ForScalarsAndArrays() var clone = original.Clone(); clone.Duration = 9.9; - clone.EaseCubicBezier![0] = 99; + // EaseCubicBezier's getter returns a defensive copy, so mutating the getter result would + // hit a throwaway array. Assign a new array to mutate the clone's actual stored state. + clone.EaseCubicBezier = [0.9, 0.2, 0.3, 0.4]; clone.Times![0] = 99; Assert.AreEqual(0.3, original.Duration); // scalar untouched From 20d0e4c687ee3bd7f9bf527698dfc5581667834e Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 16 Jun 2026 13:56:10 +0330 Subject: [PATCH 16/24] resolve review comments IX --- .../Bit.Bmotion.Demos/wwwroot/index.html | 2 +- src/Bmotion/Bit.Bmotion/Components/Bmotion.cs | 1 + .../Models/BmotionAnimationTarget.cs | 2 +- .../Bit.Bmotion/Models/BmotionDragOptions.cs | 9 +++++++-- .../Services/BmotionAnimateService.cs | 20 +++++++++++++++---- .../Services/BmotionAnimationControls.cs | 16 ++++++++------- .../Bit.Bmotion/wwwroot/bit-bmotion.js | 6 +++++- 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html index 0258483bac..9b930f65bb 100644 --- a/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html @@ -23,7 +23,7 @@
An unhandled error has occurred. Reload - 🗙 +