From ee041c46bc44c654f88282000a146dce1b02e054 Mon Sep 17 00:00:00 2001 From: msynk Date: Sat, 13 Jun 2026 12:58:54 +0330 Subject: [PATCH 1/5] 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 `