diff --git a/src/Bmotion/Bit.Bmotion.Demos/App.razor b/src/Bmotion/Bit.Bmotion.Demos/App.razor new file mode 100644 index 0000000000..cb72e0278f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/App.razor @@ -0,0 +1,13 @@ + + + + + + + Not found + +

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..8e37f658f3 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Layout/MainLayout.razor @@ -0,0 +1,19 @@ +@inherits LayoutComponentBase + + + +
+ @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..f40d7adbd8 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/AnimatePresencePage.razor @@ -0,0 +1,209 @@ +@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 sealed class Item(int id, string label) + { + public int Id { get; } = id; + public string Label { get; } = label; + // Drives the per-item AnimatePresence: flipped to false to play the exit animation + // *before* the item is removed from the collection. + public bool Present { get; set; } = true; + } + + private List _items = [new(1,"Item One"), new(2,"Item Two"), new(3,"Item Three")]; + + void AddItem() => _items.Add(new(_nextId++, $"Item {_nextId - 1}")); + + async Task RemoveItem(int id) + { + var item = _items.Find(i => i.Id == id); + if (item is null || !item.Present) return; + item.Present = false; // trigger the exit animation + StateHasChanged(); // force a render so the exit animation runs before the delay + await Task.Delay(400); // let it finish before dropping the item from the list + _items.Remove(item); + } + + private const string _presenceCode = """ + @using Bit.Bmotion + + + +
+

AnimatePresence

+ +
+
+ + + +
+
+ + +
+ + @code { + private bool _visible = true; + } + """; + + private const string _listCode = """ + @using Bit.Bmotion + + + +
+

List Items

+

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

+ +
+
+ @foreach (var item in _items) + { + + + @item.Label + + + + } +
+
+ + +
+ + @code { + private int _nextId = 4; + + private sealed class Item(int id, string label) + { + public int Id { get; } = id; + public string Label { get; } = label; + public bool Present { get; set; } = true; + } + + private List _items = [new(1,"Item One"), new(2,"Item Two"), new(3,"Item Three")]; + + void AddItem() => _items.Add(new(_nextId++, $"Item {_nextId - 1}")); + + async Task RemoveItem(int id) + { + var item = _items.Find(i => i.Id == id); + if (item is null || !item.Present) return; + item.Present = false; // trigger the exit animation + StateHasChanged(); // force a render so the exit animation runs before the delay + await Task.Delay(400); // let it finish before removing the item + _items.Remove(item); + } + } + """; +} 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..391cb3ac52 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/BasicAnimations.razor @@ -0,0 +1,166 @@ +@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; + + private const string _basicCode = """ + @using Bit.Bmotion + + + +
+

Basic Animations

+ +
+
+ +
+
+ +
+
+ + +
+ + @code { + private bool _toggled; + } + """; + + private const string _cssCode = """ + @using Bit.Bmotion + + + +
+

CSS Property Animations

+

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

+
+
+ +
+
+ +
+ + @code { + 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..5078abb657 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/DragPage.razor @@ -0,0 +1,168 @@ +@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, OnDragEnd lifecycle callbacks to react to drag state transitions.

+
+
+ +
+ @_dragInfo +
+
+
+ + +
+ +@code { + private string _dragInfo = "Drag the box"; + + void HandleDragStart() => _dragInfo = "Dragging…"; + void HandleDragEnd() => _dragInfo = "Released"; + + private const string _dragCode = """ + @using Bit.Bmotion + + + +
+

Drag

+ +
+
+ +
+ +
+

+ X-axis only + constraints +

+ +
+
+
+ """; + + private const string _dragEventsCode = """ + @using Bit.Bmotion + + + +
+

Drag Events

+

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

+
+
+ +
+ @_dragInfo +
+
+
+
+ + @code { + private string _dragInfo = "Drag the box"; + + void HandleDragStart() => _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..dd21895b00 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Gestures.razor @@ -0,0 +1,226 @@ +@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}"); + // Only the last 8 entries are ever shown; trim the rest so a long session can't grow this list unbounded. + if (_events.Count > 8) + _events.RemoveRange(0, _events.Count - 8); + StateHasChanged(); + } + + private const string _hoverTapCode = """ + @using Bit.Bmotion + + + +
+

Hover & Tap

+
+
+ +
+ +
+ + Animated Button + +
+
+
+ """; + + private const string _focusCode = """ + @using Bit.Bmotion + + + +
+

Focus

+

WhileFocus plays while an element or its descendants are focused.

+
+
+ +
+
+
+ """; + + private const string _eventsCode = """ + @using Bit.Bmotion + + + +
+

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}"); + // Only the last 8 entries are ever shown; trim the rest so a long session can't grow this list unbounded. + if (_events.Count > 8) + _events.RemoveRange(0, _events.Count - 8); + 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..c88c9fd3e2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Home.razor @@ -0,0 +1,83 @@ +@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

+
+
+ +
+
+ + +
+ +@code { + private readonly string[] _features = + [ + "Spring physics", "Tween", "Inertia", "Keyframes", + "Gestures", "Drag", "AnimatePresence", "Variants", + "whileInView", "Layout FLIP", "MotionValue", "Scroll tracking", + ".NET 8+", "Minimal JS" + ]; + + private const string _quickStart = """ + @using Bit.Bmotion + + + +
+ +
+ """; +} 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..5e40695e19 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Keyframes.razor @@ -0,0 +1,191 @@ +@page "/keyframes" + +

Keyframes

+ +
+

Keyframes

+

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

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

Color Keyframes

+

Smoothly cycle through a palette of colors.

+
+
+ +
+
+ + +
+ +@code { + private const string _keyframesCode = """ + @using Bit.Bmotion + + + +
+

Keyframes

+ +
+
+ +
+ +
+ +
+
+
+ """; + + private const string _colorKeyframesCode = """ + @using Bit.Bmotion + + + +
+

Color Keyframes

+

Smoothly cycle through a palette of colors.

+
+
+ +
+
+
+ """; +} + 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..01aa104008 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/LayoutPage.razor @@ -0,0 +1,126 @@ +@page "/layout" + +

Layout Animations

+ +
+

Layout Animations (FLIP)

+

+ Add Layout="true" to any Bmotion 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" + }; + + private const string _layoutCode = """ + @using Bit.Bmotion + + + +
+

Layout Animations (FLIP)

+ +
+
+
+ @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..d42e2c4a7e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/ScrollAnimations.razor @@ -0,0 +1,142 @@ +@page "/scroll" +@inject BmotionScrollTracker Scroll +@implements IAsyncDisposable + +

Scroll Animations

+ +
+
Window scroll progress
+
+
+
+
+ Y: @((_progressY * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% | X: @((_progressX * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% +
+
+ +
+

Scroll Tracking

+

+ Inject BmotionScrollTracker 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; + return InvokeAsync(StateHasChanged); + }); + } + } + + public async ValueTask DisposeAsync() => await Scroll.DisposeAsync(); + + private const string _scrollTrackCode = """ + @using Bit.Bmotion + @inject BmotionScrollTracker Scroll + @implements IAsyncDisposable + + + +
+
Window scroll progress
+
+
+
+
+ Y: @((_progressY * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% | X: @((_progressX * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture))% +
+
+ + @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; + return InvokeAsync(StateHasChanged); + }); + } + } + + public async ValueTask DisposeAsync() => await Scroll.DisposeAsync(); + } + """; + + private const string _inViewCode = """ + @using Bit.Bmotion + + + +
+

whileInView

+ + @for (int i = 0; i < 16; i++) + { + var color = i % 2 == 0 ? "#6c47ff" : "#ff4785"; + var idx = i; + + Section @(idx + 1) + + } +
+ """; +} 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..9214baf063 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Springs.razor @@ -0,0 +1,291 @@ +@page "/springs" + +

Springs

+ +
+

Spring Physics

+

+ Set Transition.Type = BmotionTransitionType.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, BmotionTransitionConfig Config); + + private readonly SpringDemo[] _springs = + [ + new("Stiff 400 / Damp 40", BmotionTransitionConfig.Spring(400, 40)), + new("Stiff 100 / Damp 10", BmotionTransitionConfig.Spring(100, 10)), + new("Stiff 50 / Damp 5", BmotionTransitionConfig.Spring(50, 5)), + new("Stiff 300 / Damp 70", BmotionTransitionConfig.Spring(300, 70)), + ]; + + void Remount() => _mountKey++; + + private const string _springCode = """ + @using Bit.Bmotion + + + +
+

Spring Physics

+ +
+ @foreach (var cfg in _springs) + { +
+ @cfg.Label + +
+ } +
+ + +
+ + @code { + private bool _on; + + private record SpringDemo(string Label, BmotionTransitionConfig Config); + + private readonly SpringDemo[] _springs = + [ + new("Stiff 400 / Damp 40", BmotionTransitionConfig.Spring(400, 40)), + new("Stiff 100 / Damp 10", BmotionTransitionConfig.Spring(100, 10)), + new("Stiff 50 / Damp 5", BmotionTransitionConfig.Spring(50, 5)), + new("Stiff 300 / Damp 70", BmotionTransitionConfig.Spring(300, 70)), + ]; + } + """; + + private const string _bouncyCode = """ + @using Bit.Bmotion + + + +
+

Bouncy Enter

+

Underdamped springs produce delightful overshoots on mount.

+
+
+ +
+
+ +
+ + @code { + private int _mountKey; + + void Remount() => _mountKey++; + } + """; + + private const string _repeatCode = """ + @using Bit.Bmotion + + + +
+

Repeating Springs

+ +
+
+ Mirror · infinite + +
+ +
+ Loop · 3× with delay + +
+
+ + +
+ + @code { + private bool _repeat; + } + """; +} 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..a20896aa1b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Pages/Variants.razor @@ -0,0 +1,222 @@ +@page "/variants" + +

Variants

+ +
+

Variants

+

+ Define named states in a BmotionMotionVariants 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 BmotionAnimationTarget fields so implicit conversions work cleanly in Razor attributes + private readonly BmotionAnimationTarget _hidden = "hidden"; + private readonly BmotionAnimationTarget _visible_ = "visible"; + + private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0 }), + ("visible", new BmotionAnimationProps { Opacity = 1 }) + ); + + private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }), + ("visible", new BmotionAnimationProps { Opacity = 1, X = 0 }) + ); + + private const string _variantsCode = """ + @using Bit.Bmotion + + + +
+

Variants

+ +
+
+ + @for (int i = 0; i < 5; i++) + { + var idx = i; + + Item @(idx + 1) + + } + +
+
+ + +
+ + @code { + private bool _visible = true; + + private readonly BmotionAnimationTarget _hidden = "hidden"; + private readonly BmotionAnimationTarget _visible_ = "visible"; + + private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0 }), + ("visible", new BmotionAnimationProps { Opacity = 1 }) + ); + + private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }), + ("visible", new BmotionAnimationProps { Opacity = 1, X = 0 }) + ); + } + """; + + private const string _orchestrationCode = """ + @using Bit.Bmotion + + + +
+

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 _visible2 = true; + + private readonly BmotionAnimationTarget _hidden = "hidden"; + private readonly BmotionAnimationTarget _visible_ = "visible"; + + private readonly BmotionMotionVariants _containerVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0 }), + ("visible", new BmotionAnimationProps { Opacity = 1 }) + ); + + private readonly BmotionMotionVariants _itemVariants = BmotionMotionVariants.Create( + ("hidden", new BmotionAnimationProps { Opacity = 0, X = -30 }), + ("visible", new BmotionAnimationProps { 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..a3f538194a --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Bit.Bmotion.Demos": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5070;https://localhost:5071", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor b/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor new file mode 100644 index 0000000000..ade3a42505 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Shared/CodeSnippet.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + +@* CodeSnippet renders a toggle button that shows / hides the full source code of a sample, + plus a copy-to-clipboard button. *@ + +
+
+ + + @if (_show) + { + + } +
+ + @if (_show) + { +
@((MarkupString)SyntaxHighlighter.Highlight(Code.Trim('\n')))
+ } +
+ +@code { + /// The full, copy/paste-runnable source snippet to display for the sample. + [Parameter, EditorRequired] public string Code { get; set; } = ""; + + private bool _show; + private bool _copied; + + private void Toggle() + { + _show = !_show; + _copied = false; + } + + private async Task CopyAsync() + { + try + { + _copied = await JS.InvokeAsync("bmCopyToClipboard", Code.Trim('\n')); + } + catch + { + _copied = false; + } + } +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs b/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs new file mode 100644 index 0000000000..b97caa580d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/Shared/SyntaxHighlighter.cs @@ -0,0 +1,420 @@ +using System.Text; + +namespace Bit.Bmotion.Demos.Shared; + +/// +/// A small, dependency-free Razor/C# syntax highlighter for the demo code snippets. +/// Produces HTML with <span class="tok-*"> token wrappers (already HTML-encoded) +/// and wraps the lines that belong to a Bmotion component tag (<Bmotion>, +/// <BmotionAnimatePresence>, <BmotionConfig>) in a hl-line span so they +/// stand out among the surrounding markup. +/// It is intentionally pragmatic - it covers the constructs used by the demo snippets rather +/// than being a fully spec-compliant Razor lexer. +/// +public static class SyntaxHighlighter +{ + /// Highlights Razor/C# source and returns HTML markup safe to render verbatim. + public static string Highlight(string code) + { + try + { + code = code.Replace("\r\n", "\n").Replace("\r", "\n"); + return new Lexer(code).Run(); + } + catch { return System.Net.WebUtility.HtmlEncode(code); } + } + + private sealed class LineBuf + { + public readonly StringBuilder Sb = new(); + public bool Hl; + } + + private sealed class Lexer + { + private readonly string s; + private readonly int n; + private int i; + + private readonly List lines = new(); + private LineBuf cur; + private bool tagHl; // currently emitting a Bmotion component tag + + public Lexer(string code) + { + s = code; n = code.Length; + cur = new LineBuf(); + lines.Add(cur); + } + + public string Run() + { + Markup(false); + + var outSb = new StringBuilder(); + for (int k = 0; k < lines.Count; k++) + { + var ln = lines[k]; + // Each line is rendered as its own block-level span. Empty lines get a + // zero-width space so they keep their height. Newlines are intentionally + // NOT emitted: the block layout provides the line breaks, and a literal + // '\n' next to a display:block span would render as an extra blank line. + var cls = ln.Hl ? "code-line hl-line" : "code-line"; + outSb.Append(""); + if (ln.Sb.Length == 0) outSb.Append("\u200b"); + else outSb.Append(ln.Sb); + outSb.Append(""); + } + return outSb.ToString(); + } + + // ── Vocabularies ────────────────────────────────────────────────────── + private static readonly HashSet BmotionTags = new(StringComparer.Ordinal) + { + "Bmotion", "BmotionAnimatePresence", "BmotionConfig", + }; + private static readonly HashSet Keywords = new() + { + "abstract","as","async","await","base","bool","break","byte","case","catch","char","class", + "const","continue","decimal","default","do","double","else","enum","false","finally","float", + "for","foreach","get","if","in","init","int","interface","internal","is","lock","long","new", + "namespace","null","object","out","override","params","private","protected","public","readonly", + "record","ref","return","sealed","set","short","static","string","struct","switch","this","throw", + "true","try","typeof","using","var","virtual","void","while","yield", + }; + private static readonly HashSet SingleLineDirectives = new() + { + "using","inject","implements","page","namespace","inherits","layout","attribute","model", + "rendermode","preservewhitespace","typeparam","addTagHelper", + }; + private static readonly HashSet BodyDirectives = new() { "code", "functions" }; + private static readonly HashSet BlockKeywords = new() + { + "if","else","for","foreach","while","switch","do","lock", + }; + + // ── Output helpers ──────────────────────────────────────────────────── + private void NewLine() { cur = new LineBuf(); lines.Add(cur); } + + private void RawHtml(string h) { cur.Sb.Append(h); if (tagHl) cur.Hl = true; } + + private void Out(char c) + { + if (c == '\n') { NewLine(); return; } + if (c == '\r') return; + switch (c) + { + case '&': cur.Sb.Append("&"); break; + case '<': cur.Sb.Append("<"); break; + case '>': cur.Sb.Append(">"); break; + default: cur.Sb.Append(c); break; + } + if (tagHl) cur.Hl = true; + } + + private void Plain(char c) => Out(c); + private void Plain(string t) { foreach (var c in t) Out(c); } + + private void Span(string t, string cls) + { + int start = 0; + for (int k = 0; k < t.Length; k++) + { + if (t[k] == '\n') + { + SpanSeg(t.Substring(start, k - start), cls); + NewLine(); + start = k + 1; + } + } + SpanSeg(t.Substring(start), cls); + } + + private void SpanSeg(string seg, string cls) + { + if (seg.Length == 0) return; + RawHtml(""); + Plain(seg); + RawHtml(""); + } + + private bool Match(string m) => i + m.Length <= n && string.CompareOrdinal(s, i, m, 0, m.Length) == 0; + private void SkipWs() { while (i < n && char.IsWhiteSpace(s[i])) { Plain(s[i]); i++; } } + + private string ReadIdent() + { + int st = i; + while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '_')) i++; + return s.Substring(st, i - st); + } + + // ── Markup mode ─────────────────────────────────────────────────────── + private void Markup(bool untilCloseBrace) + { + while (i < n) + { + char c = s[i]; + if (untilCloseBrace && c == '}') { Plain('}'); i++; return; } + if (c == '@') + { + if (i + 1 < n && s[i + 1] == '*') RazorComment(); + else RazorAt(); + continue; + } + if (c == '<') + { + if (Match("")) i++; + if (i < n) i += 3; + Span(s.Substring(st, i - st), "tok-com"); + } + + private void Tag() + { + RawHtml(""); + Out(s[i]); i++; // '<' + if (i < n && s[i] == '/') { Out('/'); i++; } + int st = i; + while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '-' || s[i] == ':' || s[i] == '_')) { Out(s[i]); i++; } + string name = s.Substring(st, i - st); + RawHtml(""); + + bool bm = BmotionTags.Contains(name); + if (bm) { tagHl = true; cur.Hl = true; } + + while (i < n) + { + char c = s[i]; + if (c == '>') { Span(">", "tok-tag"); i++; if (bm) tagHl = false; return; } + if (c == '/' && i + 1 < n && s[i + 1] == '>') { Span("/>", "tok-tag"); i += 2; if (bm) tagHl = false; return; } + if (char.IsWhiteSpace(c)) { Plain(c); i++; continue; } + if (c == '@') + { + i++; + string w = ReadIdent(); + Span("@" + w, "tok-attr"); + continue; + } + if (char.IsLetter(c) || c == '_') + { + int as_ = i; + while (i < n && (char.IsLetterOrDigit(s[i]) || s[i] == '-' || s[i] == ':' || s[i] == '_')) i++; + Span(s.Substring(as_, i - as_), "tok-attr"); + continue; + } + if (c == '=') { Plain('='); i++; AttrValue(); continue; } + Plain(c); i++; + } + if (bm) tagHl = false; + } + + private void AttrValue() + { + while (i < n && char.IsWhiteSpace(s[i])) { Plain(s[i]); i++; } + if (i >= n) return; + char c = s[i]; + if (c == '"' || c == '\'') + { + char q = c; + Span(q.ToString(), "tok-str"); i++; + var buf = new StringBuilder(); + void Flush() { if (buf.Length > 0) { Span(buf.ToString(), "tok-str"); buf.Clear(); } } + while (i < n) + { + char d = s[i]; + if (d == q) { Flush(); Span(q.ToString(), "tok-str"); i++; return; } + if (d == '@') + { + Flush(); + if (i + 1 < n && s[i + 1] == '*') RazorComment(); + else RazorAt(); + continue; + } + buf.Append(d); i++; + } + Flush(); + } + else + { + int st = i; + while (i < n && !char.IsWhiteSpace(s[i]) && s[i] != '>' && s[i] != '/') i++; + if (i > st) Span(s.Substring(st, i - st), "tok-str"); + } + } + + // ── Razor transition ────────────────────────────────────────────────── + private void RazorAt() + { + i++; // consume '@' + if (i >= n) { Span("@", "tok-raz"); return; } + char c = s[i]; + if (c == '(' || c == '{') { Span("@", "tok-raz"); CSharpGroup(); return; } + if (char.IsLetter(c) || c == '_') + { + string w = ReadIdent(); + if (SingleLineDirectives.Contains(w)) { Span("@" + w, "tok-key"); DirectiveLine(); return; } + if (BodyDirectives.Contains(w)) { Span("@" + w, "tok-key"); SkipWs(); if (i < n && s[i] == '{') CSharpGroup(); return; } + if (BlockKeywords.Contains(w)) + { + Span("@" + w, "tok-key"); + SkipWs(); + if (i < n && s[i] == '(') CSharpGroup(); + SkipWs(); + if (i < n && s[i] == '{') MarkupGroup(); + return; + } + Span("@", "tok-raz"); EmitIdentCS(w); MemberTail(); return; + } + Span("@", "tok-raz"); + } + + private void MemberTail() + { + while (i < n) + { + char c = s[i]; + if (c == '.' && i + 1 < n && (char.IsLetter(s[i + 1]) || s[i + 1] == '_')) + { + Plain('.'); i++; EmitIdentCS(ReadIdent()); continue; + } + if (c == '(' || c == '[') { CSharpGroup(); continue; } + break; + } + } + + private void DirectiveLine() + { + while (i < n && s[i] != '\n') + { + char c = s[i]; + if (char.IsLetter(c) || c == '_') { EmitIdentCS(ReadIdent()); continue; } + if (char.IsDigit(c)) { Number(); continue; } + Plain(c); i++; + } + } + + // ── C# mode ───────────────────────────────────────────────────────────── + private void CSharpGroup() + { + Plain(s[i]); i++; // opening bracket + int depth = 1; + while (i < n && depth > 0) + { + char c = s[i]; + if (c == '"' || c == '\'') { CSharpString(c); continue; } + if (c == '$' && i + 1 < n && s[i + 1] == '"') { InterpString(); continue; } + if (c == '@' && i + 1 < n && s[i + 1] == '"') { VerbatimString(); continue; } + if (c == '/' && i + 1 < n && s[i + 1] == '/') { LineComment(); continue; } + if (c == '/' && i + 1 < n && s[i + 1] == '*') { BlockComment(); continue; } + if (c == '(' || c == '{' || c == '[') { depth++; Plain(c); i++; continue; } + if (c == ')' || c == '}' || c == ']') { depth--; Plain(c); i++; continue; } + if (char.IsLetter(c) || c == '_') { EmitIdentCS(ReadIdent()); continue; } + if (char.IsDigit(c)) { Number(); continue; } + Plain(c); i++; + } + } + + private void EmitIdentCS(string w) + { + if (w.Length == 0) return; + if (Keywords.Contains(w)) Span(w, "tok-key"); + else if (char.IsUpper(w[0])) Span(w, "tok-type"); + else Plain(w); + } + + private void Number() + { + int st = i; + while (i < n && char.IsDigit(s[i])) i++; + if (i < n && s[i] == '.' && i + 1 < n && char.IsDigit(s[i + 1])) + { + i++; + while (i < n && char.IsDigit(s[i])) i++; + } + if (i < n && "fFdDmMlLuU".IndexOf(s[i]) >= 0) i++; + Span(s.Substring(st, i - st), "tok-num"); + } + + private void CSharpString(char q) + { + int st = i; i++; + while (i < n) + { + char c = s[i]; + if (c == '\\' && i + 1 < n) { i += 2; continue; } + if (c == q) { i++; break; } + i++; + } + Span(s.Substring(st, i - st), "tok-str"); + } + + private void InterpString() + { + int st = i; i += 2; // $" + int brace = 0; + while (i < n) + { + char c = s[i]; + if (c == '\\' && i + 1 < n) { i += 2; continue; } + if (c == '{') { if (i + 1 < n && s[i + 1] == '{') { i += 2; continue; } brace++; i++; continue; } + if (c == '}') { if (i + 1 < n && s[i + 1] == '}') { i += 2; continue; } if (brace > 0) brace--; i++; continue; } + if (c == '"' && brace == 0) { i++; break; } + if (c == '"' && brace > 0) + { + i++; + while (i < n) { char d = s[i]; if (d == '\\' && i + 1 < n) { i += 2; continue; } if (d == '"') { i++; break; } i++; } + continue; + } + i++; + } + Span(s.Substring(st, i - st), "tok-str"); + } + + private void VerbatimString() + { + int st = i; i += 2; // @" + while (i < n) + { + char c = s[i]; + if (c == '"') { if (i + 1 < n && s[i + 1] == '"') { i += 2; continue; } i++; break; } + i++; + } + Span(s.Substring(st, i - st), "tok-str"); + } + + private void LineComment() + { + int st = i; + while (i < n && s[i] != '\n') i++; + Span(s.Substring(st, i - st), "tok-com"); + } + + private void BlockComment() + { + int st = i; i += 2; + while (i < n && !(s[i] == '*' && i + 1 < n && s[i + 1] == '/')) i++; + if (i < n) i += 2; + Span(s.Substring(st, i - st), "tok-com"); + } + } +} diff --git a/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor new file mode 100644 index 0000000000..7c000e9596 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/_Imports.razor @@ -0,0 +1,12 @@ +@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.Demos.Shared +@using Bit.Bmotion 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..a87a1467b2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/app.css @@ -0,0 +1,82 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +h1:focus-visible { + outline: 2px solid #0071c1; + outline-offset: 2px; +} + +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..fc35ed5b3a --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/css/motion-samples.css @@ -0,0 +1,284 @@ +/* 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-nav a:focus-visible { + color: var(--bm-text); + outline: 2px solid var(--bm-primary); + outline-offset: 2px; + border-radius: 4px; +} + +.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; } +.btn-bm:focus-visible { + opacity: .85; + outline: 2px solid #fff; + outline-offset: 2px; +} + +/* ── 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; +} + +/* ── Code snippet toggle ───────────────────────────────────────────────────── */ + +.code-snippet { + margin-top: 1rem; +} +.code-snippet-bar { + display: flex; + align-items: center; + gap: .5rem; +} +.code-toggle { + display: inline-flex; + align-items: center; + gap: .4rem; + background: transparent; + border: 1px solid rgba(255,255,255,.15); + border-radius: 6px; + color: var(--bm-muted); + font-size: .8rem; + font-weight: 600; + padding: .4rem .8rem; + cursor: pointer; + transition: color .2s, border-color .2s, background .2s; +} +.code-toggle:hover { + color: #fff; + border-color: var(--bm-primary); + background: rgba(108,71,255,.12); +} +.code-toggle:focus-visible { + color: #fff; + border-color: var(--bm-primary); + background: rgba(108,71,255,.12); + outline: 2px solid var(--bm-primary); + outline-offset: 2px; +} +.code-toggle-icon { + font-size: .7rem; + line-height: 1; +} +.code-copy { + display: inline-flex; + align-items: center; + gap: .35rem; + background: transparent; + border: 1px solid rgba(255,255,255,.15); + border-radius: 6px; + color: var(--bm-muted); + font-size: .8rem; + font-weight: 600; + padding: .4rem .8rem; + cursor: pointer; + transition: color .2s, border-color .2s, background .2s; +} +.code-copy:hover { + color: #fff; + border-color: var(--bm-primary); + background: rgba(108,71,255,.12); +} +.code-copy:focus-visible { + color: #fff; + border-color: var(--bm-primary); + background: rgba(108,71,255,.12); + outline: 2px solid var(--bm-primary); + outline-offset: 2px; +} +.code-snippet .code-block { + margin-top: .75rem; +} +.code-snippet .code-block code { + display: block; + color: inherit; + background: none; + font-family: inherit; + font-size: inherit; +} + +/* ── Syntax highlighting tokens ────────────────────────────────────────────── */ + +.code-block .tok-com { color: #6a9955; font-style: italic; } +.code-block .tok-tag { color: #569cd6; } +.code-block .tok-attr { color: #9cdcfe; } +.code-block .tok-str { color: #ce9178; } +.code-block .tok-key { color: #569cd6; } +.code-block .tok-type { color: #4ec9b0; } +.code-block .tok-num { color: #b5cea8; } +.code-block .tok-raz { color: #c586c0; } + +/* Each source line is its own block so highlighted and plain lines share uniform spacing. */ +.code-block .code-line { + display: block; +} + +/* Lines where a Bmotion component is used, so they stand out from the markup. */ +.code-block .hl-line { + display: block; + background: rgba(108,71,255,.16); + box-shadow: inset 3px 0 0 var(--bm-primary); + margin: 0 -1.25rem; + padding: 0 1.25rem; +} + +/* ── 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..2d4de2cf61 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion.Demos/wwwroot/index.html @@ -0,0 +1,56 @@ + + + + + + + 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..ff2b6cc034 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Bit.Bmotion.csproj @@ -0,0 +1,40 @@ + + + + + + net10.0;net9.0;net8.0 + Bit.Bmotion + enable + true + + $(NoWarn);IL2091 + + README.md + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bmotion/Bit.Bmotion/BitBmotion.cs b/src/Bmotion/Bit.Bmotion/BitBmotion.cs new file mode 100644 index 0000000000..faa3f394cb --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/BitBmotion.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Bmotion; + +/// +/// Extension methods to register Bit.Bmotion services in the DI container. +/// +/// +/// +/// Platform support: Bit.Bmotion runs its animation loop over synchronous JS interop +/// and is therefore supported on Blazor WebAssembly only. It does not work on Blazor +/// Server, and it is inert during server-side prerendering (animations start once the WASM runtime +/// is interactive). Attempting to start the animation loop on a non-WebAssembly host throws +/// . +/// +/// +public static class BitBmotion +{ + /// + /// Registers all Bit.Bmotion services. + /// Call this in Program.cs before builder.Build(): + /// builder.Services.AddBitBmotionServices(); + /// + /// Blazor WebAssembly only - see the remarks on for details. + /// + /// + public static IServiceCollection AddBitBmotionServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Keep the JSON-marshaled interop DTOs trim/AOT-safe. These types cross the JS↔.NET + // boundary via reflection-based System.Text.Json (InvokeAsync, [JSInvokable] params), + // so the trimmer can't see their members as used and would otherwise strip them - leading + // to empty/failed deserialization in published WebAssembly builds. + PreserveInteropContracts(); + + // Slim browser-API interop bridge - one instance per DI scope + services.AddScoped(); + + // C# animation engine - drives all animation math in WebAssembly + services.AddScoped(); + + // Higher-level services + // BmotionScrollTracker is owned and disposed by the consuming component (like + // Framer Motion's per-component useScroll), so it must be transient. + // A scoped (app-lifetime in WASM) instance would be disposed by the first + // component to unmount, leaving its DotNetObjectReference disposed and + // causing ObjectDisposedException when another component re-observes. + services.AddTransient(); + services.AddTransient(); + services.AddScoped(); + + return services; + } + + // Roots the public properties + parameterless constructors of every type that is (de)serialized + // across JS interop so the trimmer preserves them. The [DynamicDependency] attributes take + // effect because this method is reachable from AddBitBmotionServices (an app entry point). + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionBoundingRect))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionScrollInfo))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionXY))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionViewportOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(BmotionDragConstraints))] + private static void PreserveInteropContracts() { } +} diff --git a/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs new file mode 100644 index 0000000000..8f716c59c7 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/Bmotion.cs @@ -0,0 +1,807 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.JSInterop; + +namespace Bit.Bmotion; +/// +/// The primary animation component - a drop-in replacement for any HTML element. +/// Animation math runs in the C# ; JS is used only +/// for DOM style mutation, pointer/focus events, viewport observation and FLIP. +/// +public sealed class Bmotion : ComponentBase, IAsyncDisposable +{ + // ── Injected services ────────────────────────────────────────────────────── + [Inject] private BmotionAnimationEngine Engine { get; set; } = null!; + [Inject] private BmotionInterop Interop { get; set; } = null!; + + // ── Cascaded contexts ────────────────────────────────────────────────────── + [CascadingParameter] private BmotionPresenceContext? PresenceCtx { get; set; } + [CascadingParameter] private BmotionVariantContext? VariantCtx { get; set; } + [CascadingParameter] private BmotionConfigContext? ConfigCtx { get; set; } + + // ── Core rendering parameters ──────────────────────────────────────────── + [Parameter] public string Tag { get; set; } = "div"; + [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 BmotionAnimationTarget? Initial { get; set; } + [Parameter] public BmotionAnimationTarget? Animate { get; set; } + [Parameter] public BmotionAnimationTarget? Exit { get; set; } + + // ── Gesture states ───────────────────────────────────────────────────────── + [Parameter] public BmotionAnimationTarget? WhileHover { get; set; } + [Parameter] public BmotionAnimationTarget? WhileTap { get; set; } + [Parameter] public BmotionAnimationTarget? WhileFocus { get; set; } + [Parameter] public BmotionAnimationTarget? WhileDrag { get; set; } + [Parameter] public BmotionAnimationTarget? WhileInView { get; set; } + + /// + /// If true, fires only once and never deactivates. + /// Shorthand for Viewport = new BmotionViewportOptions { 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 BmotionViewportOptions? Viewport { get; set; } + + // ── Transition ───────────────────────────────────────────────────────────── + [Parameter] public BmotionTransitionConfig? Transition { get; set; } + + // ── Variants ───────────────────────────────────────────────────────────── + [Parameter] public BmotionMotionVariants? Variants { get; set; } + + // ── Drag ───────────────────────────────────────────────────────────────── + [Parameter] public bool Drag { get; set; } + [Parameter] public BmotionDragOptions? DragOptions { get; set; } + + // ── Layout ───────────────────────────────────────────────────────────────── + [Parameter] public bool Layout { 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 BmotionAnimationTarget? _prevAnimate; + private BmotionVariantContext? _ownVariantCtx; + private string? _prevInheritedVariant; + private int _variantChildIndex = -1; + private BmotionBoundingRect? _layoutSnapshot; + // The style string most recently emitted into the render tree, and the one we've reconciled + // with the engine. When these diverge after init, Blazor has rewritten the element's inline + // style, so we re-flush the engine's live values on top to avoid resetting animated props. + private string _pendingStyle = string.Empty; + private string _committedStyle = string.Empty; + // Signatures of the gesture-event flags and viewport options currently wired up in JS. Compared + // each update so listeners/observers are re-attached only when the effective configuration + // changes (gestures are otherwise wired once and would ignore later parameter changes). + private string _eventFlagsSig = string.Empty; + private string? _viewportSig; + // Tracks whether WhileInView was set on the previous reconcile, so its removal can clear the + // active in-view gesture layer even when the (option-only) viewport signature is unchanged. + private bool _whileInViewSet; + + // ════════════════════════════════════════════════════════════════════════════ + // Rendering + // ════════════════════════════════════════════════════════════════════════════ + + /// The element tag to render, normalized to "div" when null or blank so + /// always receives a valid tag name. + private string EffectiveTag => string.IsNullOrWhiteSpace(Tag) ? "div" : Tag; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + // Sequence numbers are fixed literals (one per logical slot) rather than a running counter. + // Blazor's diffing assumes stable sequence numbers; computing them dynamically alongside + // conditional attributes shifts the numbers between renders and degrades diffing. + builder.OpenElement(0, EffectiveTag); + builder.AddAttribute(1, "id", _id); + + if (AdditionalAttributes != null) + builder.AddMultipleAttributes(2, + AdditionalAttributes.Where(kvp => !string.Equals(kvp.Key, "id", StringComparison.OrdinalIgnoreCase))); + + // Auto-inject pathLength="1" so normalized [0,1] dasharray coordinates work correctly + if (NeedsPathLengthAttr()) + builder.AddAttribute(3, "pathLength", "1"); + + if (!string.IsNullOrEmpty(Class)) + builder.AddAttribute(4, "class", Class); + + var motionStyle = BuildInitialStyle(); + var combinedStyle = string.IsNullOrEmpty(Style) ? motionStyle : motionStyle + Style; + _pendingStyle = combinedStyle ?? string.Empty; + if (!string.IsNullOrEmpty(combinedStyle)) + builder.AddAttribute(5, "style", combinedStyle); + + builder.AddElementReferenceCapture(6, r => _ref = r); + + if (Variants != null) + { + // Fall back to an inherited active variant from an ancestor so nested variant trees + // propagate the active label when this component doesn't set its own Animate.Variant. + var active = Animate?.IsVariant == true ? Animate.Variant : VariantCtx?.ActiveVariant; + // Mirror the ActiveVariant fallback: descendants inherit the initial variant label from + // an ancestor when this node defines Variants without its own local Initial variant. + var initial = Initial?.IsVariant == true ? Initial.Variant : VariantCtx?.InitialVariant; + var stagger = Transition?.StaggerChildren ?? 0; + var delayChildren = Transition?.DelayChildren ?? 0; + + // A cascaded context whose fields are mutated in place does NOT reliably notify + // descendants (CascadingValue change-detection is reference-based). So when any cascaded + // value actually changes, publish a NEW context instance - the changed reference forces + // CascadingValue to re-notify children. The child-index counter is carried over so any + // child registering after the swap still gets a stable stagger position. + if (_ownVariantCtx is null || + _ownVariantCtx.ActiveVariant != active || + _ownVariantCtx.InitialVariant != initial || + !ReferenceEquals(_ownVariantCtx.Variants, Variants) || + _ownVariantCtx.StaggerChildren != stagger || + _ownVariantCtx.DelayChildren != delayChildren) + { + var previous = _ownVariantCtx; + _ownVariantCtx = new BmotionVariantContext + { + ActiveVariant = active, + InitialVariant = initial, + Variants = Variants, + StaggerChildren = stagger, + DelayChildren = delayChildren, + }; + if (previous != null) _ownVariantCtx.SeedChildIndex(previous.NextChildIndex); + } + + builder.OpenComponent>(7); + builder.AddComponentParameter(8, "Value", _ownVariantCtx); + builder.AddComponentParameter(9, "ChildContent", ChildContent); + builder.CloseComponent(); + } + else + { + builder.AddContent(10, ChildContent); + } + builder.CloseElement(); + } + + private string BuildInitialStyle() + { + // The initial inline style exists only to avoid a flash of unstyled content before interop + // initialises; it never changes after the first paint (the engine owns live styles from + // then on), so compute it once and reuse the cached string on subsequent renders. + if (_initialStyleCache != null) return _initialStyleCache; + var props = ResolveProps(Initial); + if (props == null && Animate == null && VariantCtx?.InitialVariant is string initVariant) + props = Variants?.Get(initVariant) ?? VariantCtx.Variants?.Get(initVariant); + return _initialStyleCache = props?.ToCssStyleString() ?? string.Empty; + } + private string? _initialStyleCache; + + // ════════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ════════════════════════════════════════════════════════════════════════════ + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotnet = DotNetObjectReference.Create(this); + await InitialiseAsync(); + _initialized = true; + // The initial inline style is what the engine seeded the DOM with; mark it reconciled. + _committedStyle = _pendingStyle; + } + else if (_initialized) + { + await HandleParameterUpdateAsync(); + + // FLIP: play layout animation after DOM settles + if (_layoutSnapshot != null) + { + var snap = _layoutSnapshot; + _layoutSnapshot = null; + await PlayFlipAsync(snap); + } + + // If a re-render rewrote the inline style attribute (e.g. the consumer changed Style), + // Blazor will have wiped the engine's live transform/opacity/etc. Re-flush the current + // engine values on top so animated state isn't visibly reset. + if (_pendingStyle != _committedStyle) + { + _committedStyle = _pendingStyle; + var live = Engine.GetCurrentStyles(_id); + if (live is { Count: > 0 }) + await Interop.ApplyStylesAsync(_id, live); + } + } + } + + protected override async Task OnParametersSetAsync() + { + if (PresenceCtx is { IsExiting: true } && !_isExiting) + { + _isExiting = true; + if (_initialized) await PlayExitAsync(); + } + else if (_isExiting && PresenceCtx is not { IsExiting: true }) + { + // The presence re-entered before/after this element finished exiting. Clear the + // exiting flag (otherwise the component stays frozen forever) and force the enter + // animation to replay by invalidating the cached previous target. + _isExiting = false; + _prevAnimate = null; + _prevInheritedVariant = null; + PresenceCtx?.Register(this); // re-register in case the context was reset + } + + // 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 gesture listeners + viewport observation through the same reconciliation path used + // on later updates, so enabling/disabling a gesture after first render is handled uniformly. + await ReconcileEventListenersAsync(); + await ReconcileViewportAsync(); + + // Start enter animation + if (Animate != null) + { + var animateProps = ResolveProps(Animate); + if (animateProps != null) + { + await OnAnimationStart.InvokeAsync(); + await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), + () => OnAnimationComplete.InvokeAsync(), setAsBase: true); + } + } + else if (VariantCtx != null && (Variants != null || VariantCtx.Variants != null)) + { + // Claim a stable stagger position on first render even when no variant is active yet, + // so a parent that switches its variant later (the common hidden→visible toggle) still + // staggers its children in render order instead of collapsing every delay to zero. + _variantChildIndex = VariantCtx.RegisterChild(); + if (VariantCtx.ActiveVariant is string inheritedVariant) + { + _prevInheritedVariant = inheritedVariant; + var props = Variants?.Get(inheritedVariant) ?? VariantCtx.Variants?.Get(inheritedVariant); + if (props != null) + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), + BuildEffectiveTransitionWithDelay(VariantCtx.GetChildDelay(_variantChildIndex)), + setAsBase: true); + } + } + + _prevAnimate = Animate; + } + + private async Task HandleParameterUpdateAsync() + { + if (_isExiting) return; + + // Recovery: if the engine evicted this element after a driver fault (see + // BmotionAnimationEngine.ComputeFrame), it silently stopped animating. Re-register and + // re-seed it here so a subsequent parameter change brings it back to life. + if (!Engine.IsRegistered(_id)) + { + var seed = ResolveProps(Initial); + Engine.RegisterElement(_id, seed?.ToJsDictionary()); + _prevAnimate = null; // force the animate below to replay + _prevInheritedVariant = null; // and the variant path too + } + + // Gesture listeners and viewport observation are wired once at init; re-wire them when the + // set of needed events / viewport options changes so gestures enabled (or disabled) after + // the first render actually take effect. + await ReconcileEventListenersAsync(); + await ReconcileViewportAsync(); + + if (!BmotionAnimationTarget.AreEquivalent(_prevAnimate, Animate)) + { + var animateProps = ResolveProps(Animate); + if (animateProps != null) + { + await OnAnimationStart.InvokeAsync(); + await Engine.AnimateToAsync(_id, animateProps.ToJsDictionary(), BuildEffectiveTransition(), + () => OnAnimationComplete.InvokeAsync(), setAsBase: true); + } + _prevAnimate = Animate; + } + // Not an "else": when Animate transitions to null the block above still runs (the targets + // differ) but applies nothing, so the inherited-variant fallback must be free to run in the + // same update cycle rather than being deferred to a later rerender. + if (Animate == null && (Variants != null || VariantCtx?.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), setAsBase: true); + } + } + } + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // 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(BmotionBoundingRect 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 == BmotionTransitionType.Spring ? 600 : (t?.Duration ?? 0.5) * 1000; + string easing = t?.Type == BmotionTransitionType.Spring + ? "cubic-bezier(0.14,1,0.34,1)" + : BmotionEasingFunctions.ToCssString(t); + string? finalT = Engine.GetCurrentTransformString(_id); + + // Pause the engine's per-frame transform writes for the duration of the FLIP so the rAF + // loop and the WAAPI layout animation don't both write `transform` and tear each other. + Engine.SuspendTransformWrites(_id, dur); + + await Interop.PlayWaapiFlipAsync(_id, dx, dy, sx, sy, dur, easing, finalT); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Programmatic API + // ════════════════════════════════════════════════════════════════════════════ + + public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) + { + transition ??= BuildEffectiveTransition(); + await Engine.AnimateToAsync(_id, props.ToJsDictionary(), transition); + } + + public void Set(BmotionAnimationProps props) => Engine.SetInstant(_id, props.ToJsDictionary()); + + public async ValueTask SetAsync(BmotionAnimationProps props) + { + Engine.SetInstant(_id, props.ToJsDictionary()); + // Flush synchronous style update to DOM as individual declarations (never via cssText, + // which would replace the element's entire inline style). + var styles = props.ToCssStyleDictionary(); + 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() + { + // Deactivate unconditionally: if WhileHover was cleared while the hover layer was still + // active, a null guard here would strand the layer's styles. DeactivateGestureLayerAsync + // is a no-op when no matching layer is active. + 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) + { + await Engine.DeactivateGestureLayerAsync(_id, "tap"); + if (isInsideElement) await OnTap.InvokeAsync(); + else await OnTapCancel.InvokeAsync(); // released outside the element ⇒ tap cancelled + } + + [JSInvokable] + public async Task OnPointerCancel() + { + 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() + { + 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 BmotionXY GetCurrentXY() + { + var (x, y) = Engine.GetCurrentXY(_id); + return new BmotionXY(x, y); + } + + [JSInvokable] public async Task OnDragMove() => await OnDrag.InvokeAsync(); + + [JSInvokable] + public async Task OnPointerUp_Drag(double velX, double velY) + { + await Engine.DeactivateGestureLayerAsync(_id, "drag"); + + var dragOpt = DragOptions ?? new BmotionDragOptions(); + + if (dragOpt.SnapToOrigin) + { + var snapT = dragOpt.SnapTransition ?? new BmotionTransitionConfig + { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 }; + await Engine.AnimateToAsync(_id, + new Dictionary { ["x"] = 0.0, ["y"] = 0.0 }, snapT); + } + else + { + await Engine.EndDragAsync( + _id, velX, velY, dragOpt.Momentum, dragOpt.Constraints, + dragOpt.Axis == BmotionDragAxis.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 BmotionPanInfo + { + Point = new BmotionPointInfo { X = pointX, Y = pointY }, + Delta = new BmotionPointInfo { X = deltaX, Y = deltaY }, + Offset = new BmotionPointInfo { X = offsetX, Y = offsetY }, + Velocity = new BmotionPointInfo { X = velocityX, Y = velocityY }, + }); + } + } + + [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 (!(Viewport?.Once ?? Once)) + await Engine.DeactivateGestureLayerAsync(_id, "inview"); + await OnViewportLeave.InvokeAsync(); + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // Helpers + // ════════════════════════════════════════════════════════════════════════════ + + private static readonly HashSet _pathDrawableTags = new(StringComparer.OrdinalIgnoreCase) + { + "path", "circle", "ellipse", "line", "polyline", "polygon", "rect", + }; + + private bool NeedsPathLengthAttr() => + _pathDrawableTags.Contains(EffectiveTag) && + (AdditionalAttributes == null || + !AdditionalAttributes.Keys.Any(k => string.Equals(k, "pathLength", StringComparison.OrdinalIgnoreCase))) && + (HasPathLength(Initial) || HasPathLength(Animate) || HasPathLength(Exit) || + HasPathLength(WhileHover) || HasPathLength(WhileTap) || HasPathLength(WhileFocus) || + HasPathLength(WhileInView) || HasPathLength(WhileDrag)); + + // Resolve the effective props (direct or variant-referenced) so pathLength is detected + // whether the target carries Props directly or points at a variant label. + private bool HasPathLength(BmotionAnimationTarget? t) => + ResolveProps(t)?.PathLength != null; + + private BmotionAnimationProps? ResolveProps(BmotionAnimationTarget? target) + { + 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 BmotionTransitionConfig InstantTransition() + => new() { Type = BmotionTransitionType.Tween, Duration = 0, Delay = 0 }; + + private BmotionTransitionConfig? 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(); + // Scale every time-based field so the whole animation is consistently sped up / + // slowed down - not just the tween duration (which left delays and duration-based + // springs out of sync with the requested speed). + t.Duration *= speed; + t.Delay *= speed; + t.RepeatDelay *= speed; + if (t.VisualDuration.HasValue) t.VisualDuration *= speed; + } + return t; + } + + private BmotionTransitionConfig BuildEffectiveTransitionWithDelay(double extraDelay) + { + // Reduced motion stays instant - stagger delays are skipped too. + if (ShouldReduceMotion()) return InstantTransition(); + + var t = BuildEffectiveTransition() ?? new BmotionTransitionConfig(); + 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 || OnTapCancel.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 BmotionDragOptions(); + if (dragOpt.Axis != BmotionDragAxis.Both) d["dragAxis"] = dragOpt.Axis.ToString().ToLowerInvariant(); + d["dragElastic"] = dragOpt.Elastic; + if (dragOpt.Constraints != null) d["dragConstraints"] = dragOpt.Constraints.ToJsObject(); + if (dragOpt.DirectionLock) d["dragDirectionLock"] = true; + } + return d; + } + + /// + /// Re-wires the JS gesture listeners when the effective event set changes. Attaching always + /// runs the JS-side cleanup first, so passing an empty set also safely detaches everything. + /// + private async Task ReconcileEventListenersAsync() + { + var events = BuildEventFlags(); + var sig = SignatureOf(events); + if (sig == _eventFlagsSig) return; + _eventFlagsSig = sig; + await Interop.AttachEventListenersAsync(_id, events, _dotnet!); + } + + /// Re-observes (or stops observing) the viewport when the effective options change. + private async Task ReconcileViewportAsync() + { + // The viewport signature intentionally ignores WhileInView (it only tracks observation + // options). So if WhileInView is cleared while other viewport callbacks keep observation + // alive, the signature is unchanged and the early-return below would skip reconciliation, + // stranding an already-active in-view layer. Detect that transition explicitly and clear it. + bool whileInViewSet = WhileInView != null; + if (_whileInViewSet && !whileInViewSet) + await Engine.DeactivateGestureLayerAsync(_id, "inview"); + _whileInViewSet = whileInViewSet; + + var sig = BuildViewportSignature(); + if (sig == _viewportSig) return; + _viewportSig = sig; + if (sig == null) + { + await Interop.UnobserveViewportAsync(_id); + // Unobserving stops future intersect callbacks but doesn't undo an already-active + // in-view layer, so clear it here to avoid leaving inview styles stuck on the element. + await Engine.DeactivateGestureLayerAsync(_id, "inview"); + return; + } + if (Viewport != null) + await Interop.ObserveViewportWithOptionsAsync(_id, _dotnet!, Viewport); + else + await Interop.ObserveViewportAsync(_id, _dotnet!, Once); + } + + private string? BuildViewportSignature() + { + bool needed = WhileInView != null || OnViewportEnter.HasDelegate || OnViewportLeave.HasDelegate; + if (!needed) return null; + return Viewport != null + ? $"opt|{Viewport.Once}|{Viewport.Margin}|{Viewport.Amount}" + : $"once|{Once}"; + } + + /// Builds a stable, order-independent string signature for an event-flags dictionary. + private static string SignatureOf(Dictionary d) + { + var sb = new System.Text.StringBuilder(); + foreach (var key in d.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + sb.Append(key).Append('='); + AppendValue(sb, d[key]); + sb.Append(';'); + } + return sb.ToString(); + } + + private static void AppendValue(System.Text.StringBuilder sb, object? value) + { + switch (value) + { + case null: + sb.Append("null"); + break; + case double dbl: + sb.Append(BmotionCssFormat.Num(dbl)); + break; + case bool b: + sb.Append(b ? "1" : "0"); + break; + case IDictionary nested: + sb.Append('{'); + foreach (var key in nested.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + sb.Append(key).Append(':'); + AppendValue(sb, nested[key]); + sb.Append(','); + } + sb.Append('}'); + break; + default: + sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); + break; + } + } + + // ════════════════════════════════════════════════════════════════════════════ + // Dispose + // ════════════════════════════════════════════════════════════════════════════ + + 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/BmotionAnimatePresence.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor new file mode 100644 index 0000000000..70f5e83258 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor @@ -0,0 +1,11 @@ +@namespace Bit.Bmotion + +@* BmotionAnimatePresence 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/BmotionAnimatePresence.razor.cs b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs new file mode 100644 index 0000000000..6c7a773120 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionAnimatePresence.razor.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Bmotion; +/// +/// 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. +/// +/// +/// Limitation: presence is controlled by a single flag for the +/// whole subtree (all-or-nothing). This does not provide per-item enter/exit tracking for keyed +/// lists the way Framer Motion's keyed AnimatePresence does; wrap individual items in their +/// own if you need independent exit animations per item. +/// +/// +/// +/// +/// <BmotionAnimatePresence IsPresent="@_visible"> +/// <Bmotion Tag="div" Animate="..." Exit="..." /> +/// </BmotionAnimatePresence> +/// +/// +/// +public partial class BmotionAnimatePresence : 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 BmotionPresenceContext _presenceCtx = new(); + // Starts false so an initial IsPresent=false renders nothing (children stay unmounted) rather + // than mounting them; the OnParametersSet transitions flip it on when content should appear. + private bool _shouldRender; + // Starts false so an initial IsPresent=false is treated as "nothing was present yet" + // rather than a present→absent exit transition. + private bool _prevIsPresent; + private bool _deferEnter; + + // ═══════════════════════════════════════════════════════════════════════════ + // Lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + protected override void OnInitialized() + { + _presenceCtx.AllExitsComplete += OnAllExitsComplete; + } + + protected override void OnParametersSet() + { + if (_prevIsPresent && !IsPresent) + { + // A fresh leave invalidates any pending deferred enter; clear it so stale + // deferred-enter state can't remount the children after this exit completes. + _deferEnter = false; + + if (_presenceCtx.ChildCount > 0) + { + // Children are leaving - signal exiting state so Bmotion components play Exit + _presenceCtx.IsExiting = true; + _shouldRender = true; // keep rendering until exit completes + } + else + { + // No animatable children registered: AllExitsComplete would never fire, so + // flagging IsExiting/keeping _shouldRender true would strand the content. Drop it now. + _presenceCtx.IsExiting = false; + _shouldRender = false; + } + } + else if (!_prevIsPresent && IsPresent) + { + if (ExitBeforeEnter && _presenceCtx.IsExiting) + { + // Wait for the exiting children to finish before rendering the new ones. + _deferEnter = true; + } + else + { + // Children are re-entering + _presenceCtx.IsExiting = false; + _presenceCtx.Reset(); + _shouldRender = true; + } + } + + _prevIsPresent = IsPresent; + } + + private void OnAllExitsComplete() + { + // Ignore stale callbacks that arrive after a re-entry / reset. + if (!_presenceCtx.IsExiting) return; + + _presenceCtx.IsExiting = false; + + if (_deferEnter) + { + // Deferred re-entry: now that exits are done, render the new children. + _deferEnter = false; + _presenceCtx.Reset(); + _shouldRender = true; + } + else + { + _shouldRender = false; + } + + InvokeAsync(StateHasChanged); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor new file mode 100644 index 0000000000..d64eae35e6 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Components/BmotionConfig.razor @@ -0,0 +1,44 @@ +@namespace Bit.Bmotion + +@* BmotionConfig 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 BmotionTransitionConfig? 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 BmotionConfigContext _ctx = new(); + + protected override void OnParametersSet() + { + // Replace the context with a fresh instance (rather than mutating in place) so the cascading + // value's reference changes whenever parameters change, ensuring descendants are notified. + // The TransitionSpeed setter still coerces negative / non-finite (NaN/Infinity) speeds to 0, + // so a bad binding can't corrupt duration math or crash the render. + _ctx = new BmotionConfigContext + { + DefaultTransition = Transition, + ReduceMotion = ReduceMotion, + TransitionSpeed = TransitionSpeed, + }; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs new file mode 100644 index 0000000000..acfcf76a49 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionConfigContext.cs @@ -0,0 +1,31 @@ + +namespace Bit.Bmotion; +/// +/// Cascaded by to set library-wide defaults. +/// +public class BmotionConfigContext +{ + /// Global default transition applied when no individual transition is set. + public BmotionTransitionConfig? 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 = half speed + /// (durations are multiplied by this factor). Default: 1. + /// + /// Negative and non-finite (NaN/Infinity) values are coerced to 0 rather than throwing, + /// so a bad binding can never crash a render. This matches 's behaviour. + /// + /// + public double TransitionSpeed + { + get => _transitionSpeed; + set => _transitionSpeed = double.IsFinite(value) && value >= 0 ? value : 0; + } + private double _transitionSpeed = 1.0; +} diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs new file mode 100644 index 0000000000..a6d41bb89c --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionPresenceContext.cs @@ -0,0 +1,51 @@ + +namespace Bit.Bmotion; +/// +/// Cascaded by to signal exit state to child Bmotion components. +/// +public class BmotionPresenceContext +{ + private readonly List _children = new(); + + /// True while the children are playing their exit animation. + public bool IsExiting { get; internal set; } + + internal void Register(Bmotion child) + { + if (!_children.Contains(child)) _children.Add(child); + } + internal void Unregister(Bmotion child) + { + _children.Remove(child); + _completedChildren.Remove(child); + // A child disposed mid-exit removes itself here. Re-evaluate completion now so + // AllExitsComplete still fires when either every remaining child has already completed, or + // no children remain at all (the last one just unregistered during an active exit). + if (IsExiting && _completedChildren.Count >= _children.Count) + AllExitsComplete?.Invoke(); + } + + internal int ChildCount => _children.Count; + + private readonly HashSet _completedChildren = new(); + + internal void NotifyExitComplete(Bmotion child) + { + // Ignore unregistered children and guard against double-counting. + if (!_children.Contains(child)) return; + if (!_completedChildren.Add(child)) return; + + if (_completedChildren.Count >= _children.Count) + AllExitsComplete?.Invoke(); + } + + /// + /// Clears exit-completion bookkeeping for a fresh enter cycle. Registered children are left + /// intact — they remove themselves via when disposed, so clearing the + /// list here would desynchronise the count for any children that are reused across a toggle. + /// + internal void Reset() { _completedChildren.Clear(); } + + /// Fired when every registered child has finished its exit animation. + internal event Action? AllExitsComplete; +} diff --git a/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs new file mode 100644 index 0000000000..5153cee279 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Context/BmotionVariantContext.cs @@ -0,0 +1,41 @@ + +namespace Bit.Bmotion; +/// +/// Cascaded by a parent Bmotion component to propagate the active variant name, +/// shared variants dictionary, and stagger configuration to descendant Bmotion components. +/// +public class BmotionVariantContext +{ + 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 BmotionMotionVariants? 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 Bmotion component once on first render to obtain a stable + /// position in the stagger sequence. Returns the child's index. + /// + internal int RegisterChild() => _nextChildIndex++; + + /// The next child index that would be handed out. Used to carry the counter across + /// context instances so children registering after a variant change keep stable indices. + internal int NextChildIndex => _nextChildIndex; + + /// Seeds the child-index counter (used when a fresh context instance replaces a prior one). + internal void SeedChildIndex(int value) => _nextChildIndex = value; + + /// Returns the stagger delay in seconds for a child at the given index. + public double GetChildDelay(int childIndex) => DelayChildren + childIndex * StaggerChildren; +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs new file mode 100644 index 0000000000..b203cf2c2c --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionAnimationEngine.cs @@ -0,0 +1,581 @@ +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; + +namespace Bit.Bmotion; +/// +/// 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. +/// +/// +/// +/// Threading: the engine is intentionally lock-free. All of its mutable state +/// (the element map, per-element driver dictionaries, completion batches, dirty flags) is only +/// safe to touch from a single thread, and every entry point - the rAF +/// tick, the synchronous JS drag callbacks, and the awaited animate APIs - is expected to run on +/// the Blazor WebAssembly UI thread. Completion continuations are scheduled on +/// , which on single-threaded WASM still runs on that same +/// thread. Do not enable WebAssembly multithreading (<WasmEnableThreads>) with +/// this library: the engine has no synchronization and concurrent access would corrupt its state. +/// +/// +public sealed class BmotionAnimationEngine : IAsyncDisposable +{ + private readonly BmotionInterop _interop; + private readonly ILogger? _logger; + private readonly Dictionary _elements = new(); + private DotNetObjectReference? _dotnet; + private bool _loopRunning; + private readonly SemaphoreSlim _loopStartGate = new(1, 1); + private bool _reducedMotionDetected; + // A single in-flight detection attempt shared by all concurrent callers so the browser probe + // and live-change subscription run exactly once (reset to null on failure to allow a retry). + private Task? _reducedMotionDetection; + + // Reused across frames so the rAF tick doesn't allocate a fresh outer dictionary every ~16 ms. + // Marshaled synchronously to JS before the next ComputeFrame runs (single-threaded Blazor WASM). + private readonly Dictionary> _frameResult = new(); + + public BmotionAnimationEngine(BmotionInterop interop, ILogger? logger = null) + { + _interop = interop; + _logger = logger; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 ValueTask EnsureReducedMotionDetectedAsync() + { + if (_reducedMotionDetected) return ValueTask.CompletedTask; + // Concurrent first-callers can all pass the check above before any of them completes the + // probe. Gate them behind a single shared detection task so the browser probe and the + // live-change subscription run once rather than racing duplicate setups. + _reducedMotionDetection ??= DetectReducedMotionAsync(); + return new ValueTask(_reducedMotionDetection); + } + + private async Task DetectReducedMotionAsync() + { + try + { + OsPrefersReducedMotion = await _interop.PrefersReducedMotionAsync(); + // Mark detection complete as soon as the initial probe succeeds: the probed value is + // valid regardless of whether the live-change subscription below can be set up. + _reducedMotionDetected = true; + } + catch + { + // Detection is best-effort: if the browser probe fails we default to + // animating normally rather than letting it break element initialisation. + OsPrefersReducedMotion = false; + // Clear the shared task so a later caller can retry the probe. + _reducedMotionDetection = null; + return; + } + + try + { + // Subscribe to live OS changes so toggling prefers-reduced-motion at runtime is honoured. + // Best-effort: a watch-setup failure must not discard the successfully probed value above. + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.WatchReducedMotionAsync(_dotnet); + } + catch + { + // Watch subscription failed; the initial preference stays valid and detection stays complete. + } + } + + /// JS → C# callback fired when the OS prefers-reduced-motion setting changes. + [JSInvokable] + public void OnReducedMotionChanged(bool prefersReduced) => OsPrefersReducedMotion = prefersReduced; + + // ═══════════════════════════════════════════════════════════════════════════ + // 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 BmotionElementAnimationState(); + _elements[elementId] = state; + } + // Reference-counted: the same element may be owned by a and one or more + // concurrent AnimateAsync calls at once. + state.RefCount++; + if (initialValues != null) + state.SetInstant(initialValues); + } + + /// Release one owner; cancels animations and removes the element only when the last owner releases it. + public void UnregisterElement(string elementId) + { + if (_elements.TryGetValue(elementId, out var state)) + { + if (state.RefCount > 0) state.RefCount--; + // Other owners (e.g. an overlapping animation) still hold the element - keep it alive + // so their in-flight animations aren't stranded by a premature teardown. + if (state.RefCount > 0) return; + state.CancelAll(); + _elements.Remove(elementId); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Animation control + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Start animating to the given values. Returns immediately (fire-and-forget). + /// + /// Set to true only for the element's resting target + /// (the Animate/variant state a gesture layer should revert to). One-off programmatic + /// animations and drag snap-backs leave it false so they don't clobber the gesture base + /// (which would strand unrelated animated properties when a gesture later deactivates). + /// + /// + public async ValueTask AnimateToAsync( + string elementId, + Dictionary values, + BmotionTransitionConfig? transition, + Func? onComplete = null, + bool setAsBase = false) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + if (setAsBase) state.SetBaseAnimation(values, transition); + if (onComplete != null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.AnimateTo(values, transition, tcs); + await EnsureLoopRunningAsync(); + // .Unwrap() so the nested onComplete() Task is observed rather than dropped + // (keeps the documented fire-and-forget behaviour of this method). + // The result flag is true only on natural completion; a superseded/cancelled + // animation resolves with false so OnAnimationComplete is NOT raised for it. + _ = tcs.Task.ContinueWith( + t => t.Result ? onComplete() : Task.CompletedTask, + TaskScheduler.Default) + .Unwrap(); + } + else + { + state.AnimateTo(values, transition); + await EnsureLoopRunningAsync(); + } + } + + /// Animate to the given values and await animation completion. + public async ValueTask AnimateToAwaitAsync( + string elementId, + Dictionary values, + BmotionTransitionConfig? transition, + bool setAsBase = false) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (setAsBase) 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); + // Kick the loop for a single frame so the change is flushed to the DOM even when + // the element is otherwise at rest (an instant Set has dirty values but no active + // animation, so without this it would never be emitted). + KickLoop(); + } + } + + /// + /// Pauses rAF transform writes for an element for so a WAAPI FLIP + /// layout animation can own the transform without the engine fighting it each frame. + /// + public void SuspendTransformWrites(string elementId, double durationMs) + { + if (_elements.TryGetValue(elementId, out var state)) + state.SuspendTransformWrites(durationMs); + } + + /// + /// Returns a snapshot of the element's current CSS declarations (transform + numeric + string + + /// path values), or null when the element is unknown. Used to re-flush live styles after + /// a Blazor re-render rewrites the element's style attribute. + /// + public Dictionary? GetCurrentStyles(string elementId) + => _elements.TryGetValue(elementId, out var state) ? state.BuildSnapshotStyles() : null; + + /// Returns true if an element is currently registered with the engine. + public bool IsRegistered(string elementId) => _elements.ContainsKey(elementId); + + /// + /// Finish all animations on an element immediately, snapping every property to its target + /// (end) value, then flush the final frame to the DOM. + /// + public void Complete(string elementId) + { + if (_elements.TryGetValue(elementId, out var state)) + { + state.CompleteAll(); + KickLoop(); + } + } + + /// 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, BmotionTransitionConfig? 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, + BmotionDragConstraints? constraints, + string? axis, + BmotionTransitionConfig? snapTransition) + { + if (!_elements.TryGetValue(elementId, out var state)) return; + + state.EndDrag(); + + var (posX, posY) = state.GetCurrentXY(); + + bool inertiaXStarted = false, inertiaYStarted = false; + if (momentum) + { + // velX/velY arrive from JS already scaled to "px per frame" (~16 ms). The * 50 factor + // converts that frame-relative figure into the larger projected-distance velocity the + // exponential-decay inertia driver expects (tuned so a natural flick throws roughly the + // distance Framer Motion produces for the same gesture). + const double inertiaVelocityScale = 50.0; + if (axis != "y" && Math.Abs(velX) > 0.5) + { + var inertiaX = new BmotionTransitionConfig + { + Type = BmotionTransitionType.Inertia, + InertiaVelocity = velX * inertiaVelocityScale, + InertiaMin = constraints?.Left, + InertiaMax = constraints?.Right, + }; + var valuesX = new Dictionary { ["x"] = posX }; + state.AnimateTo(valuesX, inertiaX); + inertiaXStarted = true; + } + + if (axis != "x" && Math.Abs(velY) > 0.5) + { + var inertiaY = new BmotionTransitionConfig + { + Type = BmotionTransitionType.Inertia, + InertiaVelocity = velY * inertiaVelocityScale, + InertiaMin = constraints?.Top, + InertiaMax = constraints?.Bottom, + }; + var valuesY = new Dictionary { ["y"] = posY }; + state.AnimateTo(valuesY, inertiaY); + inertiaYStarted = true; + } + } + + // Snap-back runs independently of momentum: when momentum produced no inertia animation + // for an axis (velocity below threshold or disabled) the element can still be out of + // bounds, so any axis without an active inertia animation is corrected here. + if (constraints != null) + { + // Snap to constraint bounds + double cx = posX, cy = posY; + bool snap = false; + var snapT = snapTransition ?? new BmotionTransitionConfig + { Type = BmotionTransitionType.Spring, Stiffness = 400, Damping = 35 }; + + if (axis != "y" && !inertiaXStarted) + { + 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" && !inertiaYStarted) + { + 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" && !inertiaXStarted) snapValues["x"] = cx; + if (axis != "x" && !inertiaYStarted) 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 BmotionTransformComposer.Build(state.Transforms); + } + + /// Returns the for an element, or null. + internal BmotionElementAnimationState? 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 are no style changes this frame. (The loop keeps running + /// until the engine explicitly calls stopRafLoop once no element has active work.) + /// + [JSInvokable] + public Dictionary>? ComputeFrame(double timestamp) + { + // Clear the reused outer buffer so entries from the previous frame don't leak through. + _frameResult.Clear(); + Dictionary>? result = null; + bool anyActive = false; + List? faulted = null; + + foreach (var (id, state) in _elements) + { + try + { + var updates = state.Tick(timestamp); + if (updates is { Count: > 0 }) + { + result ??= _frameResult; + result[id] = updates; + } + if (state.HasActiveAnimations) anyActive = true; + } + catch + { + // A single malformed value or driver fault must not take down the whole loop + // (a thrown exception would propagate into the synchronous JS rAF tick and + // permanently stop animation for every element). Isolate and evict the bad + // element instead, then keep ticking the rest. + (faulted ??= new List()).Add(id); + } + } + + if (faulted != null) + { + foreach (var id in faulted) + { + if (_elements.TryGetValue(id, out var badState)) + { + try { badState.CancelAll(); } catch { /* best-effort cleanup */ } + _elements.Remove(id); + // Surface the eviction: the owning component still believes it's registered, so + // without this signal a faulted element would silently stop animating forever. + // Bmotion re-registers on its next parameter update (see IsRegistered check). + _logger?.LogWarning( + "Bmotion evicted element '{ElementId}' after its animation tick threw. " + + "Animations on it are stopped until it re-registers.", id); + } + } + } + + if (!anyActive) + StopLoopInternal(); + + return result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Loop lifecycle + // ═══════════════════════════════════════════════════════════════════════════ + + public async ValueTask EnsureLoopRunningAsync() + { + if (_loopRunning) return; + + // Concurrent callers (e.g. several elements registering on the same frame) can all pass the + // check above before any of them flips _loopRunning, which would start the rAF loop more + // than once. Serialize startup behind a gate so only the first caller starts it; the rest + // re-check _loopRunning after acquiring the gate and become no-ops. + await _loopStartGate.WaitAsync(); + try + { + if (_loopRunning) return; + + // Bit.Bmotion's animation loop relies on synchronous JS→.NET interop (the JS rAF ticker + // calls ComputeFrame synchronously). That is only available on Blazor WebAssembly; on + // Blazor Server / SSR the call would throw an opaque error, so fail fast with a clear one. + if (!_interop.IsInProcess) + throw new PlatformNotSupportedException( + "Bit.Bmotion requires synchronous JS interop and is only supported on Blazor WebAssembly. " + + "It cannot run on Blazor Server or during server-side prerendering."); + + _dotnet ??= DotNetObjectReference.Create(this); + await _interop.StartRafLoopAsync(_dotnet); + // Only flag the loop as running once startup actually succeeded; if the interop call + // throws, the flag stays false so a later call can retry instead of silently no-op'ing. + _loopRunning = true; + } + finally + { + _loopStartGate.Release(); + } + } + + private void StopLoopInternal() + { + if (!_loopRunning) return; + // Clear the flag synchronously so a re-entrant ComputeFrame this frame doesn't schedule a + // second stop, but defer the actual JS teardown behind _loopStartGate (the same gate + // EnsureLoopRunningAsync uses to start the loop). Serializing stop and start prevents a + // delayed stop from racing a restart - tearing down a freshly started loop while + // _loopRunning is true would otherwise leave the engine stuck (flagged running, JS stopped). + _loopRunning = false; + _ = StopRafLoopGatedAsync(); + + async Task StopRafLoopGatedAsync() + { + try + { + await _loopStartGate.WaitAsync(); + } + catch (ObjectDisposedException) + { + // DisposeAsync may dispose the gate while this fire-and-forget task is still + // pending. Bail out gracefully rather than surfacing an unobserved exception - + // teardown already stopped the JS loop explicitly. + return; + } + try + { + // A restart may have re-flipped _loopRunning to true after we cleared it (and + // already (re)started the JS loop). Skip the stale stop so we don't tear it down. + if (_loopRunning) return; + // Pass our own engine ref so only this engine is removed from the shared JS loop, + // leaving any other Blazor-root engines ticking. + await _interop.StopRafLoopAsync(_dotnet); + } + catch { /* mid-session stop is best-effort; teardown is ordered explicitly in DisposeAsync */ } + finally + { + // DisposeAsync may dispose the gate (line below) while this fire-and-forget task is + // still unwinding through the await above. Guard Release() so a disposal race can't + // surface as an unobserved ObjectDisposedException on this path. + try { _loopStartGate.Release(); } + catch (ObjectDisposedException) { /* gate disposed during teardown; nothing to release */ } + } + } + } + + /// + /// Fire-and-forget loop start that observes (and swallows) any fault. Used by synchronous entry + /// points (Set / SetInstant / Complete) so an unsupported-platform throw can't surface as an + /// unobserved task exception. + /// + private void KickLoop() + { + _ = KickLoopAsync(); + + async Task KickLoopAsync() + { + try { await EnsureLoopRunningAsync(); } + catch { /* loop start is best-effort here; awaited entry points surface real errors */ } + } + } + + public async ValueTask DisposeAsync() + { + foreach (var (_, state) in _elements) + state.CancelAll(); + _elements.Clear(); + + // Await the loop stop before disposing _dotnet so the JS call doesn't marshal a + // disposed DotNetObjectReference. StopLoopInternal's fire-and-forget path is fine for + // mid-session stops, but during teardown we must order it explicitly. + _loopRunning = false; + if (_dotnet != null) + { + try { await _interop.StopRafLoopAsync(_dotnet); } catch { /* ignore during teardown */ } + try { await _interop.UnwatchReducedMotionAsync(_dotnet); } catch { /* ignore during teardown */ } + _dotnet.Dispose(); + _dotnet = null; + } + _loopStartGate.Dispose(); + // BmotionInterop is owned and disposed by the DI container (it is registered scoped), + // so the engine must not dispose it here or it would be disposed twice. + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs new file mode 100644 index 0000000000..f74f21bb01 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorInterpolator.cs @@ -0,0 +1,141 @@ +namespace Bit.Bmotion; +/// +/// Pure-C# RGBA color parsing and linear interpolation. +/// Handles #hex, rgb(), rgba(), hsl(), and hsla() formats. +/// +internal static partial class BmotionColorInterpolator +{ + // Source-generated regexes: faster than the static Regex cache and trim-safe (no runtime + // IL emit), which matters because this assembly is built with IsTrimmable=true. + [System.Text.RegularExpressions.GeneratedRegex( + @"^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex RgbRegex(); + + [System.Text.RegularExpressions.GeneratedRegex( + @"^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?(?:\s*,\s*([\d.]+))?\s*\)$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)] + private static partial System.Text.RegularExpressions.Regex HslRegex(); + /// + /// Linearly interpolates between two CSS color strings at progress (0–1). + /// + /// Interpolation is performed per-channel in the sRGB color space (matching Framer Motion's + /// default). This is fast and predictable, but mixing complementary colors can pass through a + /// desaturated mid-point (e.g. blue→yellow briefly looks grey). A perceptual space such as + /// OKLab would avoid that at extra cost; sRGB is intentional here for parity and performance. + /// + /// + public static string Lerp(string from, string to, double t) + { + var f = Parse(from); + var tt = Parse(to); + if (f == null || tt == null) return to; + return Lerp(f, tt, t); + } + + /// + /// Interpolates between two pre-parsed RGBA channel arrays (as produced by ). + /// Drivers parse their colors once up-front and call this each tick, avoiding the per-frame + /// regex/parse cost of the string overload. + /// + public static string Lerp(double[] from, double[] to, double t) + { + int r = (int)Math.Round(Math.Clamp(from[0] + (to[0] - from[0]) * t, 0, 255)); + int g = (int)Math.Round(Math.Clamp(from[1] + (to[1] - from[1]) * t, 0, 255)); + int b = (int)Math.Round(Math.Clamp(from[2] + (to[2] - from[2]) * t, 0, 255)); + double a = Math.Clamp(from[3] + (to[3] - from[3]) * t, 0, 1); + return $"rgba({r},{g},{b},{BmotionCssFormat.Num(a, "G4")})"; + } + + /// 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 ────────────────────────────────────────────────────────────── + + /// + /// Parses a CSS color into its [r, g, b, a] channels (0–255 for RGB, 0–1 for alpha), + /// or returns null when the string is not a recognised color. Exposed to drivers so + /// they can parse keyframes once instead of on every tick. + /// + internal static double[]? Parse(string c) + { + if (string.IsNullOrEmpty(c)) return null; + + 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 is not (6 or 8)) return null; + if (!TryHex(h[..2], out int r) || + !TryHex(h[2..4], out int g) || + !TryHex(h[4..6], out int b)) + return null; + double alpha = 1.0; + if (h.Length == 8) + { + if (!TryHex(h[6..8], out int a)) return null; + alpha = a / 255.0; + } + return [r, g, b, alpha]; + } + + // rgb() / rgba() + var m = RgbRegex().Match(c); + if (m.Success) + { + // Use TryParse so malformed numerics like "1..2" fall back to null (and the Lerp + // string overload then returns the target) instead of throwing a FormatException. + if (!BmotionCssFormat.TryParse(m.Groups[1].Value, out double mr) || + !BmotionCssFormat.TryParse(m.Groups[2].Value, out double mg) || + !BmotionCssFormat.TryParse(m.Groups[3].Value, out double mb)) + return null; + double ma = 1.0; + if (m.Groups[4].Success && !BmotionCssFormat.TryParse(m.Groups[4].Value, out ma)) + return null; + return [mr, mg, mb, ma]; + } + + // hsl() / hsla() + var mh = HslRegex().Match(c); + if (mh.Success) + { + if (!BmotionCssFormat.TryParse(mh.Groups[1].Value, out double h2) || + !BmotionCssFormat.TryParse(mh.Groups[2].Value, out double s2raw) || + !BmotionCssFormat.TryParse(mh.Groups[3].Value, out double l2raw)) + return null; + double a2 = 1.0; + if (mh.Groups[4].Success && !BmotionCssFormat.TryParse(mh.Groups[4].Value, out a2)) + return null; + var rgb2 = HslToRgb(h2, s2raw / 100.0, l2raw / 100.0); + return [rgb2[0], rgb2[1], rgb2[2], a2]; + } + + return null; + } + + private static bool TryHex(string s, out int value) + => int.TryParse(s, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out value); + + 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/BmotionColorKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs new file mode 100644 index 0000000000..bee5014e0b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorKeyframesDriver.cs @@ -0,0 +1,174 @@ +namespace Bit.Bmotion; +/// Keyframe animation driver for CSS color string properties. +internal sealed class BmotionColorKeyframesDriver : IBmotionAnimationDriver +{ + private readonly string[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly BmotionRepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private bool _reversed; + private string[] _curFrames; + private readonly double[]?[] _curChannels; + + public BmotionColorKeyframesDriver(string[] frames, BmotionTransitionConfig config, Action apply) + { + if (frames is null || frames.Length < 2) + throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames)); + if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay) + || config.Duration < 0 || config.Delay < 0 || config.RepeatDelay < 0) + // NaN/infinite timing values poison _startTime in the progress math, pushing invalid + // values through _apply. Negative durations keep t below 1.0 so the animation never + // completes. Reject both up front (matches the numeric keyframes driver). + throw new ArgumentException( + "Duration, Delay and RepeatDelay must be finite, non-negative values.", nameof(config)); + if (config.Times != null && config.Times.Length != frames.Length) + throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); + if (config.Times != null) + { + // Times feed the segment math; non-monotonic or out-of-range values produce + // negative/zero segment lengths and NaN output, so reject them up front. + for (int i = 0; i < config.Times.Length; i++) + { + // double.NaN/infinity slip past the relational checks below (every comparison with + // NaN is false), so reject non-finite entries explicitly before the range/order tests. + if (!double.IsFinite(config.Times[i])) + throw new ArgumentException("Times values must be finite.", nameof(config)); + if (config.Times[i] < 0 || config.Times[i] > 1) + throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config)); + if (i > 0 && config.Times[i] < config.Times[i - 1]) + throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config)); + } + } + + _frames = (string[])frames.Clone(); + _curFrames = (string[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.IsInfiniteRepeat; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + // Clone the caller's Times so the in-place MirrorTimes mutation never touches their config. + _times = config.Times != null + ? (double[])config.Times.Clone() + : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + var globalEase = BmotionEasingFunctions.Get(config); + _eases = Enumerable.Repeat(globalEase, n - 1).ToArray(); + + // Parse each frame's color once up-front; Tick() then only interpolates pre-parsed + // channels instead of running the color regex on every frame (~60 fps). + _curChannels = new double[]?[n]; + for (int i = 0; i < n; i++) + _curChannels[i] = BmotionColorInterpolator.Parse(_curFrames[i]); + } + + public bool Tick(double timestamp) + { + // Freeze at the current value on cancel (consistent with the other drivers); callers + // remove the driver immediately after Cancel(), so this branch is defensive only. + if (_cancelled) return true; + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + 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.Clamp(segT, 0.0, 1.0)); + var ca = _curChannels[seg]; + var cb = _curChannels[seg + 1]; + // Fall back to the raw target frame string when a color couldn't be parsed + // (matches the string Lerp returning 'to' for unparseable input). + _apply(ca != null && cb != null + ? BmotionColorInterpolator.Lerp(ca, cb, easedT) + : _curFrames[seg + 1]); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + // Mirror ping-pongs: reverse the playback direction every cycle (0→1, 1→0, …). + // Reverse plays the frames backwards repeatedly (1→0, 1→0, …): reverse once on the + // first repeat, then keep that order so each subsequent cycle replays in reverse + // rather than toggling back to forward. + if (_repeatType == BmotionRepeatType.Mirror) + { + Array.Reverse(_curFrames); + Array.Reverse(_curChannels); + MirrorTimes(_times); + } + else if (_repeatType == BmotionRepeatType.Reverse && !_reversed) + { + Array.Reverse(_curFrames); + Array.Reverse(_curChannels); + MirrorTimes(_times); + _reversed = true; + } + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() + { + // Mirror/Reverse don't always terminate on the last frame, so snap to the correct natural + // terminal frame (computed from the original forward-order _frames): + // • Mirror ping-pongs each pass (total passes = _repeat + 1). An even count ends back on + // the first frame, an odd count on the last. + // • Reverse plays forward once then replays reversed for every later pass, so it ends on + // the last frame only when there are no repeats, otherwise on the first frame. + // Infinite repeats have no natural end, so fall through to the last frame. + if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror) + { + _apply((_repeat + 1) % 2 == 0 ? _frames[0] : _frames[^1]); + return; + } + if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse) + { + _apply(_repeat == 0 ? _frames[^1] : _frames[0]); + return; + } + _apply(_frames[^1]); + } + + /// + /// Mirrors a (possibly non-uniform) times array in place so segment durations line up with the + /// reversed frame order: newTimes[i] = 1 - times[n-1-i]. Applying it twice restores the + /// original, matching how Mirror/Reverse alternate direction each iteration. + /// + private static void MirrorTimes(double[] times) + { + int n = times.Length; + for (int i = 0; i < n / 2; i++) + { + double a = 1 - times[n - 1 - i]; + double b = 1 - times[i]; + times[i] = a; + times[n - 1 - i] = b; + } + if (n % 2 == 1) times[n / 2] = 1 - times[n / 2]; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs new file mode 100644 index 0000000000..a78caeca2d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionColorTweenDriver.cs @@ -0,0 +1,116 @@ + +namespace Bit.Bmotion; +/// Tween animation driver for CSS color string properties. +internal sealed class BmotionColorTweenDriver : IBmotionAnimationDriver +{ + private readonly string _to; + private readonly string _from; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly Func _easeFn; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly BmotionRepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private bool _reversed; + private string _curFrom; + private string _curTo; + private double[]? _curFromCh; + private double[]? _curToCh; + + public BmotionColorTweenDriver(string from, string to, BmotionTransitionConfig config, Action apply) + { + if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay) + || config.Duration < 0) + // NaN/infinite timing values poison _startTime in the progress math, pushing invalid + // values through _apply. A negative duration keeps t below 1.0 so the tween never + // completes. Reject both up front (a zero duration is allowed: it completes instantly). + throw new ArgumentException( + "Duration, Delay and RepeatDelay must be finite values and Duration must be non-negative.", nameof(config)); + + _curFrom = _from = from; + _curTo = _to = to; + // Parse once up-front so Tick() doesn't run the color regex ~60 times per second. + _curFromCh = BmotionColorInterpolator.Parse(from); + _curToCh = BmotionColorInterpolator.Parse(to); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _easeFn = BmotionEasingFunctions.Get(config); + _repeat = config.Repeat; + _isInfinite = config.IsInfiniteRepeat; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + } + + public bool Tick(double timestamp) + { + if (_cancelled) 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); + // Fall back to the raw target string when a color couldn't be parsed (matches the + // string Lerp's behaviour of returning 'to' for unparseable input). + _apply(_curFromCh != null && _curToCh != null + ? BmotionColorInterpolator.Lerp(_curFromCh, _curToCh, p) + : _curTo); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + // Mirror ping-pongs: swap from/to every pass (0→1, 1→0, …). + // Reverse plays the colour backwards repeatedly (1→0, 1→0, …): swap once on the + // first repeat, then keep that order so each later cycle replays in reverse rather + // than toggling back to forward (matches the keyframe drivers). + if (_repeatType == BmotionRepeatType.Mirror) + { + (_curFrom, _curTo) = (_curTo, _curFrom); + (_curFromCh, _curToCh) = (_curToCh, _curFromCh); + } + else if (_repeatType == BmotionRepeatType.Reverse && !_reversed) + { + (_curFrom, _curTo) = (_curTo, _curFrom); + (_curFromCh, _curToCh) = (_curToCh, _curFromCh); + _reversed = true; + } + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() + { + // Mirror ping-pongs each pass, so the natural terminal colour depends on how many passes + // run: total passes = _repeat + 1. An even count ends back on _from, an odd count on _to. + if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror) + { + _apply((_repeat + 1) % 2 == 0 ? _from : _to); + return; + } + // Reverse plays forward once (ending on _to) then replays reversed for every later pass + // (ending on _from), so it ends on _to only when there are no repeats. + if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse) + { + _apply(_repeat == 0 ? _to : _from); + return; + } + // Loop (and infinite repeats, which have no natural end) terminate on _to. + _apply(_to); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs new file mode 100644 index 0000000000..651600c337 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionCssFormat.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace Bit.Bmotion; +/// +/// Centralised, culture-invariant number↔CSS conversion helpers. +/// +/// Every numeric value that ends up in a CSS string (transforms, opacity, colours, +/// dimensions, dash arrays, …) MUST be formatted through here. Using the default +/// would honour the current culture and emit a comma +/// decimal separator in locales such as de-DE or fr-FR, producing invalid +/// CSS like translate(1,5px,0px). All formatting and parsing here pins +/// . +/// +/// +internal static class BmotionCssFormat +{ + /// + /// Formats a double as an invariant-culture string using compact "G6" formatting + /// (up to 6 significant digits). This is lossy, not full round-trip precision, but keeps + /// emitted CSS short while staying visually accurate for animation values. + /// + public static string Num(double value) + => value.ToString("G6", CultureInfo.InvariantCulture); + + /// Formats a double as an invariant-culture string using the given numeric format. + public static string Num(double value, string format) + => value.ToString(format, CultureInfo.InvariantCulture); + + /// Parses a double using invariant culture. Returns false on failure. + public static bool TryParse(string? text, out double value) + => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + + /// Parses a double using invariant culture, throwing on failure. + public static double Parse(string text) + => double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs new file mode 100644 index 0000000000..4aa6d5f071 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionEasingFunctions.cs @@ -0,0 +1,93 @@ + +namespace Bit.Bmotion; +/// +/// Pure-C# easing functions. Ported from the original JS implementation. +/// Cached delegates avoid re-allocation for common easing types. +/// +internal static class BmotionEasingFunctions +{ + // ── 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(BmotionTransitionConfig config) + { + if (config.EaseCubicBezier is { Length: 4 } cb) + return CubicBezier(cb[0], cb[1], cb[2], cb[3]); + + return config.Ease switch + { + BmotionEasing.Linear => t => t, + BmotionEasing.EaseIn => _easeIn, + BmotionEasing.EaseOut => _easeOut, + BmotionEasing.EaseInOut => _easeInOut, + BmotionEasing.CircIn => t => 1 - Math.Sqrt(1 - t * t), + BmotionEasing.CircOut => t => Math.Sqrt(1 - (t - 1) * (t - 1)), + BmotionEasing.CircInOut => t => t < 0.5 + ? (1 - Math.Sqrt(1 - 4 * t * t)) / 2 + : (Math.Sqrt(1 - Math.Pow(2 * t - 2, 2)) + 1) / 2, + BmotionEasing.BackIn => _backIn, + BmotionEasing.BackOut => _backOut, + BmotionEasing.BackInOut => _backInOut, + BmotionEasing.Anticipate => t => t < 0.5 + ? _backIn(t * 2) / 2 + : _easeOut(t * 2 - 1) / 2 + 0.5, + _ => _easeOut, + }; + } + + /// Returns a CSS easing string for use with the Web Animations API (FLIP). + public static string ToCssString(BmotionTransitionConfig? config) + { + if (config == null) return "ease"; + if (config.EaseCubicBezier is { Length: 4 } cb) + return $"cubic-bezier({BmotionCssFormat.Num(cb[0])},{BmotionCssFormat.Num(cb[1])},{BmotionCssFormat.Num(cb[2])},{BmotionCssFormat.Num(cb[3])})"; + return config.Ease switch + { + BmotionEasing.Linear => "linear", + BmotionEasing.EaseIn => "ease-in", + BmotionEasing.EaseOut => "ease-out", + BmotionEasing.EaseInOut => "ease-in-out", + // Circ* have no CSS keyword - map to their closest cubic-bezier approximations. + BmotionEasing.CircIn => "cubic-bezier(0.55,0,1,0.45)", + BmotionEasing.CircOut => "cubic-bezier(0,0.55,0.45,1)", + BmotionEasing.CircInOut => "cubic-bezier(0.85,0,0.15,1)", + // Back* map exactly to the cubic-beziers used by Get(...). + BmotionEasing.BackIn => "cubic-bezier(0.31455,-0.37755,0.69245,1.37755)", + BmotionEasing.BackOut => "cubic-bezier(0.33915,0,0.68085,1.4)", + BmotionEasing.BackInOut => "cubic-bezier(0.68987,-0.45,0.32,1.45)", + // Anticipate has no CSS equivalent; use the backIn curve as the nearest fallback. + BmotionEasing.Anticipate => "cubic-bezier(0.31455,-0.37755,0.69245,1.37755)", + _ => "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; + // True derivative dx/du of the cubic-bezier x(u): + // 3(1-u)²·x1 + 6(1-u)u·(x2-x1) + 3u²·(1-x2) + double dbx = 3 * (1 - u) * (1 - u) * x1 + + 6 * (1 - u) * u * (x2 - x1) + + 3 * u * u * (1 - x2); + 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/BmotionElementAnimationState.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs new file mode 100644 index 0000000000..7ac8822062 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionElementAnimationState.cs @@ -0,0 +1,696 @@ + +namespace Bit.Bmotion; +/// +/// Per-element animation state - the C# equivalent of the JS ElementState class. +/// Holds current transform / numeric / color values, active animation drivers, +/// and gesture-layer bookkeeping. Called by +/// every rAF tick. +/// +internal sealed class BmotionElementAnimationState +{ + // ── Live CSS values ─────────────────────────────────────────────────────── + + /// Current values of transform components (x, y, scale, rotate, …). + // Case-insensitive so keys accepted by BmotionTransformComposer.IsTransformProp (which compares + // OrdinalIgnoreCase) match the canonical lowercase keys the composer reads when emitting. + internal readonly Dictionary Transforms = new(StringComparer.OrdinalIgnoreCase); + + /// Current values of numeric non-transform properties (opacity, pathLength, …). + internal readonly Dictionary NumericValues = new(); + + /// Current values of color / string properties (backgroundColor, color, …). + internal readonly Dictionary StringValues = new(); + + /// + /// Number of live owners holding this element (a wrapping <Bmotion>, controllers, and + /// in-flight AnimateAsync calls). The engine only tears the element down when this hits zero. + /// + internal int RefCount; + + // ── Active animations ───────────────────────────────────────────────────── + private readonly Dictionary _activeAnims = new(); + + // ── Gesture layer stack ──────────────────────────────────────────────────── + private static readonly string[] GesturePriority = ["drag", "focus", "tap", "hover", "inview"]; + private readonly Dictionary _gestureLayers = new(); + private Dictionary? _baseValues; + private BmotionTransitionConfig? _baseTransition; + + // ── Animation completion tracking ───────────────────────────────────────── + // Each AnimateTo call that carries a TaskCompletionSource registers a "batch": the set of + // property keys that call is animating. The batch resolves true only when every one of its + // keys finishes naturally; it resolves false if any of its keys is superseded or cancelled. + // Tracking per-batch (rather than a single per-element source) means overlapping animations on + // different properties of the same element no longer resolve each other prematurely. + private sealed class CompletionBatch + { + public required TaskCompletionSource Source { get; init; } + public required HashSet Keys { get; init; } + public bool Interrupted { get; set; } + } + private readonly List _batches = new(); + + // ── Drag state ──────────────────────────────────────────────────────────── + private bool _isDragging; + + // ── Dirty flags for CSS build ───────────────────────────────────────────── + private bool _transformDirty; + private readonly HashSet _dirtyProps = new(); + + // Transform writes are paused while a WAAPI FLIP layout animation owns the element's + // transform, so the rAF engine doesn't fight (and visually tear) the layout animation. + // The window is measured against Tick timestamps so no extra timer/interop is needed. + private double _transformSuspendMs; + private double _transformSuspendStart = -1; + + // Reused across frames to avoid allocating a fresh CSS-update dictionary every rAF tick. + // Safe because the synchronous JS interop marshals the returned dictionary before the next + // Tick runs (single-threaded Blazor WASM), so the buffer is never read after it is cleared. + private readonly Dictionary _updateBuffer = new(); + + public bool HasActiveAnimations => _activeAnims.Count > 0 || _isDragging; + + // ═══════════════════════════════════════════════════════════════════════════ + // Tick - called every rAF frame + // ═══════════════════════════════════════════════════════════════════════════ + + public Dictionary? Tick(double timestamp) + { + // Nothing to do only when there are no drivers, no drag, and no pending + // instant (SetInstant) changes still waiting to be emitted. + if (_activeAnims.Count == 0 && !_isDragging && !_transformDirty && _dirtyProps.Count == 0) + return null; + + if (_isDragging) _transformDirty = true; // drag always refreshes transform + + // Advance all drivers. Iterate over a snapshot because driver.Tick can invoke a user + // OnUpdate callback that re-enters and mutates _activeAnims (e.g. starts/cancels an + // animation on this same element), which would otherwise corrupt the enumeration. + List? completed = null; + foreach (var (key, driver) in _activeAnims.ToArray()) + { + // The driver may have been removed - or replaced with a different driver - by a + // re-entrant callback earlier in this loop. Skip unless the live driver for this key + // is still the exact one captured in the snapshot, so a stale driver can't tick and + // later evict the replacement at the removal step below. + if (!_activeAnims.TryGetValue(key, out var current) || !ReferenceEquals(current, driver)) continue; + if (driver.Tick(timestamp)) + (completed ??= new List()).Add(key); + } + + if (completed != null) + foreach (var key in completed) + { + _activeAnims.Remove(key); + NotePropFinished(key, interrupted: false); // natural completion + } + + if (!_transformDirty && _dirtyProps.Count == 0) return null; + + // ── Build CSS style update dict (reused buffer) ──────────────────────── + var updates = _updateBuffer; + updates.Clear(); + + // While a FLIP layout animation owns the transform, hold transform writes back so the two + // animators don't fight; the dirty flag is preserved so the latest transform is flushed + // the moment the suspension window ends. + bool transformSuspended = IsTransformSuspended(timestamp); + if (_transformDirty && !transformSuspended) + updates["transform"] = BmotionTransformComposer.Build(Transforms); + + foreach (var prop in _dirtyProps) + { + if (prop is "pathLength" or "pathSpacing") + { + // Compose strokeDasharray from the normalized pathLength + pathSpacing pair. + double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); + double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0); + double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); + updates["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing); + // Offset combines the "draw from end" baseline (1 - len) with any explicit pathOffset. + updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); + } + else if (prop == "pathOffset") + { + double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); + double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); + updates["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); + } + else if (prop.StartsWith("--")) + { + if (NumericValues.TryGetValue(prop, out double nv)) + updates[prop] = BmotionCssFormat.Num(nv); + else if (StringValues.TryGetValue(prop, out string? sv)) + updates[prop] = sv; + } + else if (NumericValues.TryGetValue(prop, out double numVal)) + { + updates[prop] = BmotionCssFormat.Num(numVal); + } + else if (StringValues.TryGetValue(prop, out string? strVal)) + { + updates[prop] = strVal; + } + } + + // Reset dirty flags now that this frame's changes have been emitted. The transform flag is + // kept set while suspended so the pending transform flushes once the FLIP window ends. + if (!transformSuspended) _transformDirty = false; + _dirtyProps.Clear(); + + return updates.Count > 0 ? updates : null; + } + + // ── Transform suspension (used by FLIP layout animations) ───────────────── + + /// Pause rAF transform writes for (measured from the next tick). + internal void SuspendTransformWrites(double durationMs) + { + if (durationMs <= 0) return; + _transformSuspendMs = durationMs; + _transformSuspendStart = -1; // armed on the next tick + } + + private bool IsTransformSuspended(double timestamp) + { + if (_transformSuspendMs <= 0) return false; + if (_transformSuspendStart < 0) _transformSuspendStart = timestamp; + if (timestamp - _transformSuspendStart < _transformSuspendMs) return true; + // Window elapsed - clear so transforms resume. + _transformSuspendMs = 0; + _transformSuspendStart = -1; + return false; + } + + /// + /// Builds a full snapshot of the element's current CSS (transform + numeric + string + path + /// values), regardless of dirty flags. Used to re-flush live styles to the DOM after a Blazor + /// re-render rewrites the element's style attribute. + /// + internal Dictionary? BuildSnapshotStyles() + { + var d = new Dictionary(); + + if (Transforms.Count > 0) + { + var tr = BmotionTransformComposer.Build(Transforms); + if (!string.IsNullOrEmpty(tr)) d["transform"] = tr; + } + + bool hasPath = NumericValues.ContainsKey("pathLength") + || NumericValues.ContainsKey("pathSpacing") + || NumericValues.ContainsKey("pathOffset"); + if (hasPath) + { + double len = Math.Clamp(NumericValues.GetValueOrDefault("pathLength", 1.0), 0, 1); + double spacing = NumericValues.GetValueOrDefault("pathSpacing", 1.0); + double offset = NumericValues.GetValueOrDefault("pathOffset", 0.0); + d["strokeDasharray"] = BmotionCssFormat.Num(len) + " " + BmotionCssFormat.Num(spacing); + d["strokeDashoffset"] = BmotionCssFormat.Num(1 - len - offset); + } + + foreach (var (prop, value) in NumericValues) + { + if (prop is "pathLength" or "pathSpacing" or "pathOffset") continue; + d[prop] = BmotionCssFormat.Num(value); + } + foreach (var (prop, value) in StringValues) + d[prop] = value; + + return d.Count > 0 ? d : null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Animation control + // ═══════════════════════════════════════════════════════════════════════════ + + public void AnimateTo( + Dictionary values, + BmotionTransitionConfig? transition, + TaskCompletionSource? completionSource = null) + { + // Cheap scan for any non-null target (no allocation). + bool any = false; + foreach (var v in values.Values) + if (v != null) { any = true; break; } + if (!any) { completionSource?.TrySetResult(true); return; } + + // Track which keys actually get an active driver so the completion batch only waits on + // animations that can finish (keys that snap instantly resolve immediately). + HashSet? driverKeys = completionSource != null ? new HashSet() : null; + int activeBefore; + + foreach (var (key, value) in values) + { + if (value == null) continue; + var perKey = transition?.Properties?.GetValueOrDefault(key) ?? transition ?? new BmotionTransitionConfig(); + // Superseding an in-flight driver for this key interrupts any completion batch that + // owned it (resolves that batch with false once its remaining keys settle). + CancelProp(key); + activeBefore = _activeAnims.Count; + + if (TryGetDoubleArray(value, out double[]? doubleFrames)) + { + // Keyframe drivers require at least two frames (they build n-1 segments and + // divide by n-1 when distributing times). Degenerate arrays would otherwise + // throw and (via ComputeFrame) stall the whole loop, so handle them here: + // 0 frames -> nothing to do; 1 frame -> snap to that single value. + if (doubleFrames!.Length >= 2) + CreateNumericKeyframesDriver(key, doubleFrames, perKey); + else if (doubleFrames.Length == 1) + CreateNumericDriver(key, doubleFrames[0], perKey); + } + else if (IsColorProp(key) && TryGetStringArray(value, out string[]? strFrames)) + { + if (strFrames!.Length >= 2) + CreateColorKeyframesDriver(key, strFrames, perKey); + else if (strFrames.Length == 1) + CreateColorDriver(key, strFrames[0], perKey); + } + else if (IsColorProp(key) && value is string colorStr) + CreateColorDriver(key, colorStr, perKey); + else if (value is string dimStr) + CreateCssDimensionDriver(key, dimStr, perKey); + else if (TryGetStringArray(value, out string[]? otherFrames) && otherFrames!.Length > 0) + { + // Non-colour string keyframes (e.g. dimension arrays) have no interpolating driver; + // snap to the final frame so the value still lands on its destination. + StringValues[key] = otherFrames[^1]; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive + _dirtyProps.Add(key); + } + else if (TryConvertToDouble(value, out double numeric)) + CreateNumericDriver(key, numeric, perKey); + // else: an unconvertible value (e.g. an arbitrary object in a user Keyframes dict) is + // skipped rather than throwing - a bad value can't take down the init / event path. + + // Record the key if this iteration created a live driver for the completion batch. + if (driverKeys != null && _activeAnims.Count > activeBefore && _activeAnims.ContainsKey(key)) + driverKeys.Add(key); + } + + // Register the completion batch (if any): it resolves once every key that got a driver + // finishes. If nothing animates (all snapped instantly), resolve immediately. + if (completionSource != null) + { + if (driverKeys is { Count: > 0 }) + _batches.Add(new CompletionBatch { Source = completionSource, Keys = driverKeys }); + else + completionSource.TrySetResult(true); + } + } + + public void SetInstant(Dictionary values) + { + foreach (var (key, value) in values) + { + if (value == null) continue; + // Cancel any in-flight driver for this property so the instant value is authoritative + // and isn't overwritten on the next tick by an ongoing animation. + CancelProp(key); + if (BmotionTransformComposer.IsTransformProp(key)) + { + if (TryConvertToDouble(value, out double tv)) + { + Transforms[key] = tv; + _transformDirty = true; + } + } + else if (IsColorProp(key) && value is string colorStr) + { + StringValues[key] = colorStr; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive + _dirtyProps.Add(key); + } + else if (value is string dimStr) + { + StringValues[key] = dimStr; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive + _dirtyProps.Add(key); + } + else if (TryConvertToDouble(value, out double nv)) + { + NumericValues[key] = nv; + StringValues.Remove(key); // keep numeric/string stores mutually exclusive + _dirtyProps.Add(key); + } + } + } + + public void Cancel(string[]? properties) + { + if (properties == null || properties.Length == 0) + CancelAll(); + else + { + // CancelProp interrupts any completion batch that owns the property, so callers of + // AnimateToAwaitAsync resolve (with false) instead of hanging forever. + foreach (var p in properties) + CancelProp(p); + } + } + + internal void CancelAll() + { + foreach (var driver in _activeAnims.Values) + driver.Cancel(); + _activeAnims.Clear(); + ResolveAllBatches(false); // cancelled, not completed + } + + /// + /// Finish all running animations immediately, snapping every property to its target + /// (end) value. Unlike (which freezes in place), this applies + /// the final frame so the element settles on the destination state. + /// + internal void CompleteAll() + { + // Snapshot the drivers before iterating: driver.Complete() applies the final value, which + // can invoke a user OnUpdate callback that re-enters and mutates _activeAnims (e.g. starts + // a new animation on this element). Iterating the live Values collection would then throw. + foreach (var driver in _activeAnims.Values.ToArray()) + driver.Complete(); + _activeAnims.Clear(); + ResolveAllBatches(true); // snapped to end values = completed + } + + internal void CancelProp(string key) + { + if (_activeAnims.TryGetValue(key, out var driver)) + { + driver.Cancel(); + _activeAnims.Remove(key); + NotePropFinished(key, interrupted: true); // cancelled / superseded + } + } + + // ── Completion-batch bookkeeping ────────────────────────────────────────── + + /// + /// Records that is no longer animating. Removes it from any pending + /// completion batch; a batch resolves once all its keys are gone (true only if none of them + /// were interrupted - i.e. every key finished naturally). + /// + private void NotePropFinished(string key, bool interrupted) + { + for (int i = _batches.Count - 1; i >= 0; i--) + { + var b = _batches[i]; + if (!b.Keys.Remove(key)) continue; + if (interrupted) b.Interrupted = true; + if (b.Keys.Count == 0) + { + b.Source.TrySetResult(!b.Interrupted); + _batches.RemoveAt(i); + } + } + } + + private void ResolveAllBatches(bool result) + { + foreach (var b in _batches) + b.Source.TrySetResult(result); + _batches.Clear(); + } + + private static bool TryConvertToDouble(object value, out double result) + { + try + { + result = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException) + { + result = 0; + return false; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Gesture layer management + // ═══════════════════════════════════════════════════════════════════════════ + + public void SetBaseAnimation(Dictionary values, BmotionTransitionConfig? transition) + { + _baseValues = values; + _baseTransition = transition; + } + + public void ActivateGestureLayer(string gesture, Dictionary values, BmotionTransitionConfig? transition) + { + _gestureLayers[gesture] = new GestureLayer(values, transition); + + // Respect gesture priority: don't let a lower-priority gesture animate over a + // higher-priority one that is already active (mirrors DeactivateGestureLayer). + int newPriority = Array.IndexOf(GesturePriority, gesture); + if (newPriority >= 0) + { + foreach (var other in _gestureLayers.Keys) + { + if (other == gesture) continue; + int otherPriority = Array.IndexOf(GesturePriority, other); + if (otherPriority >= 0 && otherPriority < newPriority) return; // higher-priority layer wins + } + } + + AnimateTo(values, transition); + } + + public void DeactivateGestureLayer(string gesture) + { + if (!_gestureLayers.Remove(gesture, out var removed)) + return; + + // Build the target the element should revert to: the base animation overlaid with + // every still-active gesture layer (lowest priority first so higher priority wins). + var target = new Dictionary(); + BmotionTransitionConfig? transition = _baseTransition; + + if (_baseValues != null) + foreach (var kv in _baseValues) + target[kv.Key] = kv.Value; + + for (int i = GesturePriority.Length - 1; i >= 0; i--) + { + if (_gestureLayers.TryGetValue(GesturePriority[i], out var layer)) + { + foreach (var kv in layer.Values) + target[kv.Key] = kv.Value; + transition = layer.Transition; // highest-priority remaining layer wins the transition + } + } + + // Any property the removed layer set but no remaining layer/base defines must animate + // back to its identity value, otherwise it would stay stuck at the gesture value. + foreach (var key in removed.Values.Keys) + { + if (target.ContainsKey(key)) continue; + if (BmotionTransformComposer.IsTransformProp(key)) + target[key] = DefaultTransformValue(key); + else if (!IsColorProp(key)) // colours have no safe identity to revert to + target[key] = DefaultNumericValue(key); + } + + if (target.Count > 0) + AnimateTo(target, transition); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 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, BmotionTransitionConfig config) + { + bool isTransform = BmotionTransformComposer.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); + + // Wire the optional per-frame OnUpdate callback (single-value numeric animations). + if (config.OnUpdate is { } onUpdate) + { + var inner = apply; + apply = v => { inner(v); onUpdate(v); }; + } + + IBmotionAnimationDriver driver = config.Type switch + { + BmotionTransitionType.Spring => new BmotionSpringDriver(from, toValue, config, apply), + BmotionTransitionType.Inertia => new BmotionInertiaDriver(from, config, apply), + _ => new BmotionTweenDriver(from, toValue, config, apply), + }; + + _activeAnims[key] = driver; + } + + private void CreateColorDriver(string key, string toValue, BmotionTransitionConfig config) + { + string from = StringValues.GetValueOrDefault(key, "rgba(0,0,0,0)"); + _activeAnims[key] = new BmotionColorTweenDriver(from, toValue, config, v => ApplyString(key, v)); + } + + private void CreateNumericKeyframesDriver(string key, double[] frames, BmotionTransitionConfig config) + { + bool isTransform = BmotionTransformComposer.IsTransformProp(key); + Action apply = isTransform + ? v => ApplyTransform(key, v) + : v => ApplyNumeric(key, v); + _activeAnims[key] = new BmotionNumericKeyframesDriver(frames, config, apply); + } + + private void CreateColorKeyframesDriver(string key, string[] frames, BmotionTransitionConfig config) + { + _activeAnims[key] = new BmotionColorKeyframesDriver(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; + // Keep the numeric/string stores mutually exclusive: Tick emits a prop from NumericValues + // first, so a stale string entry for the same key would otherwise be masked (and vice versa). + StringValues.Remove(key); + _dirtyProps.Add(key); + } + + private void ApplyString(string key, string value) + { + StringValues[key] = value; + // Mutually exclusive with NumericValues (see ApplyNumeric): a stale numeric entry for this + // key would mask the string update during Tick emission. + NumericValues.Remove(key); + _dirtyProps.Add(key); + } + + // ── 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.EndsWith("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) + { + // Convert each element defensively: a mixed array like [0, "bad"] must not throw. + var arr = new double[oa.Length]; + for (int i = 0; i < oa.Length; i++) + { + if (oa[i] is null) return false; + try { arr[i] = Convert.ToDouble(oa[i], System.Globalization.CultureInfo.InvariantCulture); } + catch (Exception e) when (e is FormatException or InvalidCastException or OverflowException) + { return false; } + } + result = arr; + return true; + } + // Any other numeric sequence (int[], float[], List, …). Strings are excluded so + // colour keyframes still fall through to TryGetStringArray. + if (value is System.Collections.IEnumerable seq && value is not string) + { + var list = new List(); + foreach (var item in seq) + { + if (item is string || item is null) return false; + try { list.Add(Convert.ToDouble(item, System.Globalization.CultureInfo.InvariantCulture)); } + catch { return false; } + } + if (list.Count > 0) { result = list.ToArray(); return true; } + } + return false; + } + + private void CreateCssDimensionDriver(string key, string toValue, BmotionTransitionConfig config) + { + // If both from and to are the same unit, interpolate numerically. + // Otherwise just snap to the new value immediately. + 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 BmotionTweenDriver(fromNum, toNum, config, + v => ApplyString(key, BmotionCssFormat.Num(v) + toUnit)); + } + else + { + // Snap and mark dirty - no interpolation possible across different units. + StringValues[key] = toValue; + NumericValues.Remove(key); // keep numeric/string stores mutually exclusive + _dirtyProps.Add(key); + } + } + + 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) return false; // a single string is not a keyframe array + if (value is string[] sa) { result = sa; return true; } + if (value is IEnumerable se) { result = se.ToArray(); return true; } + if (value is object[] oa && oa.Length > 0 && oa.All(x => x is string)) + { + result = oa.Cast().ToArray(); + return true; + } + return false; + } + + private sealed record GestureLayer(Dictionary Values, BmotionTransitionConfig? Transition); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs new file mode 100644 index 0000000000..e2d6fa8010 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionInertiaDriver.cs @@ -0,0 +1,70 @@ + +namespace Bit.Bmotion; +/// +/// Exponential-decay inertia driver. Decelerates from an initial velocity toward +/// an optional projected target, with optional bounds clamping. +/// +internal sealed class BmotionInertiaDriver : IBmotionAnimationDriver +{ + 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 BmotionInertiaDriver(double from, BmotionTransitionConfig config, Action apply) + { + _start = from; + _timeConstantSec = config.TimeConstant > 0 ? config.TimeConstant / 1000.0 : 1e-6; + // Rest delta must be strictly positive, otherwise the completion test + // (|projected - pos| < restDelta) can never pass and the driver runs forever. + _restDelta = config.InertiaRestDelta > 0 ? config.InertiaRestDelta : 0.01; + _delayMs = config.Delay * 1000; + _apply = apply; + + 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) 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 tau = _timeConstantSec > 0 ? _timeConstantSec : 1e-6; + double pos = _start + _delta * (1 - Math.Exp(-_elapsed / tau)); + _apply(pos); + + if (Math.Abs(_projected - pos) < _restDelta) + { + _apply(_projected); + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() => _apply(_projected); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs new file mode 100644 index 0000000000..029599a7d3 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionNumericKeyframesDriver.cs @@ -0,0 +1,172 @@ +namespace Bit.Bmotion; +/// Keyframe animation driver for numeric (double) properties. +internal sealed class BmotionNumericKeyframesDriver : IBmotionAnimationDriver +{ + private readonly double[] _frames; + private readonly double _durationMs; + private readonly double _delayMs; + private readonly double[] _times; + private readonly Func[] _eases; + private readonly int _repeat; + private readonly bool _isInfinite; + private readonly BmotionRepeatType _repeatType; + private readonly double _repeatDelayMs; + private readonly Action _apply; + + private double _startTime = -1; + private bool _cancelled; + private int _iteration; + private bool _reversed; + private double[] _curFrames; + + public BmotionNumericKeyframesDriver(double[] frames, BmotionTransitionConfig config, Action apply) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(apply); + if (frames is null || frames.Length < 2) + throw new ArgumentException("Keyframe animations require at least 2 frames.", nameof(frames)); + if (!double.IsFinite(config.Duration) || !double.IsFinite(config.Delay) || !double.IsFinite(config.RepeatDelay)) + // NaN/infinite timing values poison _startTime in the progress math (e.g. a NaN delay + // makes _startTime NaN), pushing invalid values through _apply. Reject them up front. + throw new ArgumentException( + "Duration, Delay and RepeatDelay must be finite values.", nameof(config)); + if (config.Times != null && config.Times.Length != frames.Length) + throw new ArgumentException("Times array length must match the number of frames.", nameof(config)); + if (config.Times != null) + { + // Times feed the Interpolate segment math; non-monotonic or out-of-range values produce + // negative/zero segment lengths and NaN output, so reject them up front. + for (int i = 0; i < config.Times.Length; i++) + { + // double.NaN/infinity slip past the relational checks below (every comparison with + // NaN is false), so reject non-finite entries explicitly before the range/order tests. + if (!double.IsFinite(config.Times[i])) + throw new ArgumentException("Times values must be finite.", nameof(config)); + if (config.Times[i] < 0 || config.Times[i] > 1) + throw new ArgumentException("Times values must be within the range [0, 1].", nameof(config)); + if (i > 0 && config.Times[i] < config.Times[i - 1]) + throw new ArgumentException("Times values must be in monotonically ascending order.", nameof(config)); + } + } + + _frames = (double[])frames.Clone(); + _curFrames = (double[])frames.Clone(); + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _repeat = config.Repeat; + _isInfinite = config.IsInfiniteRepeat; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + + int n = frames.Length; + // Clone the caller's Times so the in-place MirrorTimes mutation never touches their config. + _times = config.Times != null + ? (double[])config.Times.Clone() + : Enumerable.Range(0, n).Select(i => (double)i / (n - 1)).ToArray(); + + // Per-segment easing array. Per-segment easing isn't exposed on the transition config yet, + // so every segment currently shares the single configured easing function; the array shape + // is kept so adding per-segment curves later doesn't change the interpolation code path. + _eases = new Func[n - 1]; + var globalEase = BmotionEasingFunctions.Get(config); + for (int i = 0; i < n - 1; i++) + _eases[i] = globalEase; + } + + public bool Tick(double timestamp) + { + // Freeze at the current value on cancel (consistent with the other drivers); callers + // remove the driver immediately after Cancel(), so this branch is defensive only. + if (_cancelled) return true; + + if (_startTime < 0) _startTime = timestamp + _delayMs; + if (timestamp < _startTime) { _apply(_curFrames[0]); return false; } + + double t = _durationMs > 0 ? Math.Min((timestamp - _startTime) / _durationMs, 1.0) : 1.0; + _apply(Interpolate(_curFrames, _times, _eases, t)); + + if (t >= 1.0) + { + if (_isInfinite || _iteration < _repeat) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + // Mirror ping-pongs: reverse the playback direction every cycle (0→1, 1→0, …). + // Reverse plays the frames backwards repeatedly (1→0, 1→0, …): reverse once on the + // first repeat, then keep that order so each subsequent cycle replays in reverse + // rather than toggling back to forward. + if (_repeatType == BmotionRepeatType.Mirror) + { + Array.Reverse(_curFrames); + MirrorTimes(_times); + } + else if (_repeatType == BmotionRepeatType.Reverse && !_reversed) + { + Array.Reverse(_curFrames); + MirrorTimes(_times); + _reversed = true; + } + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() + { + // Mirror/Reverse don't always terminate on the last frame, so snap to the correct natural + // terminal frame (computed from the original forward-order _frames): + // • Mirror ping-pongs each pass (total passes = _repeat + 1). An even count ends back on + // the first frame, an odd count on the last. + // • Reverse plays forward once then replays reversed for every later pass, so it ends on + // the last frame only when there are no repeats, otherwise on the first frame. + // Infinite repeats have no natural end, so fall through to the last frame. + if (!_isInfinite && _repeatType == BmotionRepeatType.Mirror) + { + _apply((_repeat + 1) % 2 == 0 ? _frames[0] : _frames[^1]); + return; + } + if (!_isInfinite && _repeatType == BmotionRepeatType.Reverse) + { + _apply(_repeat == 0 ? _frames[^1] : _frames[0]); + return; + } + _apply(_frames[^1]); + } + + /// + /// Mirrors a (possibly non-uniform) times array in place so segment durations line up with the + /// reversed frame order: newTimes[i] = 1 - times[n-1-i]. Applying it twice restores the + /// original, matching how Mirror/Reverse alternate direction each iteration. + /// + private static void MirrorTimes(double[] times) + { + int n = times.Length; + for (int i = 0; i < n / 2; i++) + { + double a = 1 - times[n - 1 - i]; + double b = 1 - times[i]; + times[i] = a; + times[n - 1 - i] = b; + } + if (n % 2 == 1) times[n / 2] = 1 - times[n / 2]; + } + + 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.Clamp(segT, 0.0, 1.0)); + return frames[seg] + (frames[seg + 1] - frames[seg]) * easedT; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs new file mode 100644 index 0000000000..71f099857d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionSpringDriver.cs @@ -0,0 +1,127 @@ + +namespace Bit.Bmotion; +/// +/// Semi-implicit Euler spring physics driver for numeric properties. +/// Automatically subdivides each frame to maintain numerical stability for +/// high-stiffness / high-damping configurations. +/// +internal sealed class BmotionSpringDriver : IBmotionAnimationDriver +{ + 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 BmotionRepeatType _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 BmotionSpringDriver(double from, double to, BmotionTransitionConfig 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) = BmotionTransitionConfig.SpringFromBounce(vd, config.Bounce.Value, config.Mass); + } + + _k = k; + _d = d; + // Mass divides the acceleration each sub-step; a value <= 0 would yield NaN/Infinity and + // trap the spring (the rest test would never pass). Fall back to the default mass of 1. + _m = config.Mass > 0 ? config.Mass : 1.0; + _vel = _initialVel = config.Velocity; + // Rest thresholds are scaled by the animation's magnitude so large-range springs (e.g. + // x: 0→1000) settle in proportion to their distance instead of chasing an absolute 0.01px/ + // 0.01px-per-sec target for many extra frames. Small ranges keep the absolute thresholds. + double range = Math.Abs(to - from); + double restScale = range > 1.0 ? range : 1.0; + // Clamp to a small positive floor: a non-positive RestSpeed/RestDelta would make the + // completion gate (Abs(vel) < restSpeed && Abs(pos-target) < restDelta) unsatisfiable, + // leaving the spring ticking forever. + const double minRest = 1e-4; + _restSpeed = Math.Max(config.RestSpeed * restScale, minRest); + _restDelta = Math.Max(config.RestDelta * restScale, minRest); + _currentDelayMs = config.Delay * 1000; + _repeatDelayMs = config.RepeatDelay * 1000; + _repeat = config.Repeat; + _isInfinite = config.IsInfiniteRepeat; + _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) 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) + { + if (!_isInfinite) _iteration++; + // Mirror/Reverse ping-pong back to the start; Loop replays from the origin. + if (_repeatType is BmotionRepeatType.Mirror or BmotionRepeatType.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; + + public void Complete() => _apply(_target); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs new file mode 100644 index 0000000000..efe9af7ce2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTransformComposer.cs @@ -0,0 +1,60 @@ +namespace Bit.Bmotion; +/// +/// Builds a CSS transform string from a dictionary of individual transform components. +/// Mirrors the JS buildTransformString function. +/// +internal static class BmotionTransformComposer +{ + 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({BmotionCssFormat.Num(persp)}px)"); + + double x = t.GetValueOrDefault("x"); + double y = t.GetValueOrDefault("y"); + double z = t.GetValueOrDefault("z"); + if (x != 0 || y != 0 || z != 0) + parts.Add(z != 0 + ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)" + : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); + + if (t.TryGetValue("scale", out double scale) && scale != 1) + parts.Add($"scale({BmotionCssFormat.Num(scale)})"); + else + { + if (t.TryGetValue("scaleX", out double sx) && sx != 1) parts.Add($"scaleX({BmotionCssFormat.Num(sx)})"); + if (t.TryGetValue("scaleY", out double sy) && sy != 1) parts.Add($"scaleY({BmotionCssFormat.Num(sy)})"); + } + + // rotateZ / rotate aliases: prefer a non-zero rotateZ, otherwise fall back to rotate so a + // zero rotateZ doesn't mask a meaningful rotate value. + double rz = t.TryGetValue("rotateZ", out double rz2) && rz2 != 0 ? rz2 : t.GetValueOrDefault("rotate"); + if (rz != 0) parts.Add($"rotate({BmotionCssFormat.Num(rz)}deg)"); + if (t.TryGetValue("rotateX", out double rx) && rx != 0) parts.Add($"rotateX({BmotionCssFormat.Num(rx)}deg)"); + if (t.TryGetValue("rotateY", out double ry) && ry != 0) parts.Add($"rotateY({BmotionCssFormat.Num(ry)}deg)"); + + if (t.TryGetValue("skewX", out double skx) && skx != 0) parts.Add($"skewX({BmotionCssFormat.Num(skx)}deg)"); + if (t.TryGetValue("skewY", out double sky) && sky != 0) parts.Add($"skewY({BmotionCssFormat.Num(sky)}deg)"); + + return string.Join(" ", parts); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs new file mode 100644 index 0000000000..a0346b9908 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/BmotionTweenDriver.cs @@ -0,0 +1,67 @@ + +namespace Bit.Bmotion; +/// Tween (duration-based) animation driver for numeric properties. +internal sealed class BmotionTweenDriver : IBmotionAnimationDriver +{ + 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 BmotionRepeatType _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 BmotionTweenDriver(double from, double to, BmotionTransitionConfig config, Action apply) + { + _curFrom = from; + _curTo = _to = to; + _durationMs = config.Duration * 1000; + _delayMs = config.Delay * 1000; + _easeFn = BmotionEasingFunctions.Get(config); + _repeat = config.Repeat; + _isInfinite = config.IsInfiniteRepeat; + _repeatType = config.RepeatType; + _repeatDelayMs = config.RepeatDelay * 1000; + _apply = apply; + } + + public bool Tick(double timestamp) + { + if (_cancelled) 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) + { + if (!_isInfinite) _iteration++; + _startTime = timestamp + _repeatDelayMs; + if (_repeatType == BmotionRepeatType.Mirror || _repeatType == BmotionRepeatType.Reverse) + (_curFrom, _curTo) = (_curTo, _curFrom); + return false; + } + return true; + } + return false; + } + + public void Cancel() => _cancelled = true; + + public void Complete() => _apply(_to); +} diff --git a/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs new file mode 100644 index 0000000000..8fab5d113e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Engine/IBmotionAnimationDriver.cs @@ -0,0 +1,22 @@ +namespace Bit.Bmotion; +/// +/// 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 IBmotionAnimationDriver +{ + /// + /// 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, freezing the value at its current (intermediate) position. + void Cancel(); + + /// Finish immediately by applying the animation's final target value. + void Complete(); +} diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs new file mode 100644 index 0000000000..05da7f517b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionBoundingRect.cs @@ -0,0 +1,12 @@ +namespace Bit.Bmotion; + +/// DOM bounding rect returned by getBoundingRect in JS. +public sealed class BmotionBoundingRect +{ + public double X { get; set; } + public double Y { get; set; } + public double Width { get; set; } + public double Height { get; set; } + public double Top { get; set; } + public double Left { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs new file mode 100644 index 0000000000..965099e6e6 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionInterop.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Bmotion; +/// +/// Slim C# wrapper around the browser-API bridge in BitBmotion.js. +/// Only calls browser-native APIs; all animation logic lives in the C# engine. +/// +public sealed class BmotionInterop : IAsyncDisposable +{ + private readonly Lazy> _moduleTask; + + public BmotionInterop(IJSRuntime js) + { + IsInProcess = js is IJSInProcessRuntime; + _moduleTask = new Lazy>( + () => js.InvokeAsync( + "import", "./_content/Bit.Bmotion/bit-bmotion.js").AsTask()); + } + + /// + /// true when the JS runtime supports synchronous interop (Blazor WebAssembly). + /// Bit.Bmotion's animation loop and drag handlers rely on synchronous JS↔.NET calls, so the + /// library only functions on WebAssembly. This is checked before the rAF loop starts. + /// + public bool IsInProcess { get; } + + 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 for the given engine reference (or all engines when null). + public async ValueTask StopRafLoopAsync(DotNetObjectReference? dotnetRef = null) where T : class + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("stopRafLoop", dotnetRef); + } + + // ── 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"); + + /// + /// Subscribes to live changes of the prefers-reduced-motion media query. JS calls + /// OnReducedMotionChanged(bool) on the engine ref whenever the OS preference changes. + /// + public async ValueTask WatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class + => await (await Module()).InvokeVoidAsync("watchReducedMotion", dotnetRef); + + /// Unsubscribes the engine ref from prefers-reduced-motion change notifications. + public async ValueTask UnwatchReducedMotionAsync(DotNetObjectReference dotnetRef) where T : class + { + if (!_moduleTask.IsValueCreated) return; + await (await Module()).InvokeVoidAsync("unwatchReducedMotion", dotnetRef); + } + + // ── Style application ───────────────────────────────────────────────────── + + /// Instantly apply a CSS styles object to a DOM element (for set() calls). + 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, BmotionViewportOptions 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); + } + + // ── 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) return; + var module = await Module(); + // Stop the rAF loop before disposing the module so no callbacks remain scheduled against a + // disposed module reference (passing null stops all engines). This keeps the JS side from + // invoking ComputeFrame on a torn-down reference during teardown. + await module.InvokeVoidAsync("stopRafLoop", new object?[] { null }); + await module.DisposeAsync(); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs new file mode 100644 index 0000000000..f58ac861ec --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Interop/BmotionXY.cs @@ -0,0 +1,17 @@ +namespace Bit.Bmotion; + +/// +/// A simple x/y pair marshalled to JS at drag start (serialised as { x, y }). +/// Used instead of a boxed anonymous type so the shape is explicit and trim/AOT friendly. +/// +public readonly struct BmotionXY +{ + public BmotionXY(double x, double y) + { + X = x; + Y = y; + } + + public double X { get; init; } + public double Y { get; init; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs new file mode 100644 index 0000000000..f96dcca00c --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationProps.cs @@ -0,0 +1,342 @@ +namespace Bit.Bmotion; + +/// +/// Describes a set of animatable CSS / transform properties - the "what" of an animation. +/// Assign to Initial, Animate, Exit, WhileHover, WhileTap, etc. +/// +/// +/// Security: string-valued properties (, , +/// , , …) are written verbatim into the element's +/// inline style. They are intended for developer-authored values; binding untrusted end-user input +/// to them risks CSS injection into the element's style. +/// +public class BmotionAnimationProps +{ + // ── Transform properties ────────────────────────────────────────────────── + 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. + /// + /// The generated stroke-dasharray/stroke-dashoffset values are normalised to a + /// unit path length, so the target SVG element must declare pathLength="1" for the + /// drawing to render correctly. + /// + /// + public double? PathLength { get; set; } + /// Offset along the path (0–1). + public double? PathOffset { get; set; } + /// 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 BmotionAnimationProps + /// { + /// 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) + { + if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--" + 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({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)"); + else + transforms.Add($"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); + } + if (Scale.HasValue && Scale.Value != 1) + { + transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + } + else + { + if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); + } + // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0 + // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). + double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); + if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); + if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); + if (Perspective.HasValue) transforms.Insert(0, $"perspective({BmotionCssFormat.Num(Perspective.Value)}px)"); + + if (transforms.Count > 0) sb.Append($"transform:{string.Join(" ", transforms)};"); + + if (Opacity.HasValue) sb.Append($"opacity:{BmotionCssFormat.Num(Opacity.Value)};"); + if (BackgroundColor != null) sb.Append($"background-color:{BackgroundColor};"); + if (Color != null) sb.Append($"color:{Color};"); + if (BorderColor != null) sb.Append($"border-color:{BorderColor};"); + if (OutlineColor != null) sb.Append($"outline-color:{OutlineColor};"); + 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 (BoxShadow != null) sb.Append($"box-shadow:{BoxShadow};"); + if (PathLength.HasValue) + { + double clamped = Math.Max(0, Math.Min(1, PathLength.Value)); + double spacing = Math.Max(0, Math.Min(1, PathSpacing ?? 1.0)); + double offset = Math.Max(0, Math.Min(1, PathOffset ?? 0.0)); + sb.Append($"stroke-dasharray:{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)};"); + sb.Append($"stroke-dashoffset:{BmotionCssFormat.Num(1 - clamped - offset)};"); + } + + if (CssVars != null) + foreach (var kv in CssVars) + { + if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--" + sb.Append($"{kv.Key}:{kv.Value};"); + } + + return sb.ToString(); + } + + /// + /// Render these props as a dictionary of individual CSS declarations + /// (camelCase keys suitable for element.style[prop] = value). + /// Used by instant set() calls so we update only the specified + /// declarations instead of replacing the element's entire inline style + /// (which assigning cssText would do). + /// + internal Dictionary ToCssStyleDictionary() + { + var d = new Dictionary(); + + var transforms = new List(); + if (Perspective.HasValue) transforms.Add($"perspective({BmotionCssFormat.Num(Perspective.Value)}px)"); + if (X.HasValue || Y.HasValue || Z.HasValue) + { + double x = X ?? 0, y = Y ?? 0, z = Z ?? 0; + transforms.Add(z != 0 + ? $"translate3d({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px,{BmotionCssFormat.Num(z)}px)" + : $"translate({BmotionCssFormat.Num(x)}px,{BmotionCssFormat.Num(y)}px)"); + } + if (Scale.HasValue && Scale.Value != 1) + { + transforms.Add($"scale({BmotionCssFormat.Num(Scale.Value)})"); + } + else + { + if (ScaleX.HasValue && ScaleX.Value != 1) transforms.Add($"scaleX({BmotionCssFormat.Num(ScaleX.Value)})"); + if (ScaleY.HasValue && ScaleY.Value != 1) transforms.Add($"scaleY({BmotionCssFormat.Num(ScaleY.Value)})"); + } + // Prefer a non-zero rotateZ, otherwise fall back to rotate, so an explicit RotateZ = 0 + // doesn't mask a meaningful Rotate value (matches BmotionTransformComposer). + double rotateZ = RotateZ.HasValue && RotateZ.Value != 0 ? RotateZ.Value : (Rotate ?? 0); + if (rotateZ != 0) transforms.Add($"rotate({BmotionCssFormat.Num(rotateZ)}deg)"); + if (RotateX.HasValue && RotateX.Value != 0) transforms.Add($"rotateX({BmotionCssFormat.Num(RotateX.Value)}deg)"); + if (RotateY.HasValue && RotateY.Value != 0) transforms.Add($"rotateY({BmotionCssFormat.Num(RotateY.Value)}deg)"); + if (SkewX.HasValue && SkewX.Value != 0) transforms.Add($"skewX({BmotionCssFormat.Num(SkewX.Value)}deg)"); + if (SkewY.HasValue && SkewY.Value != 0) transforms.Add($"skewY({BmotionCssFormat.Num(SkewY.Value)}deg)"); + if (transforms.Count > 0) d["transform"] = string.Join(" ", transforms); + + if (Opacity.HasValue) d["opacity"] = BmotionCssFormat.Num(Opacity.Value); + 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) + { + double clamped = Math.Max(0, Math.Min(1, PathLength.Value)); + double spacing = Math.Max(0, Math.Min(1, PathSpacing ?? 1.0)); + double offset = Math.Max(0, Math.Min(1, PathOffset ?? 0.0)); + d["strokeDasharray"] = $"{BmotionCssFormat.Num(clamped)} {BmotionCssFormat.Num(spacing)}"; + d["strokeDashoffset"] = BmotionCssFormat.Num(1 - clamped - offset); + } + + if (CssVars != null) + foreach (var kv in CssVars) + { + if (!kv.Key.StartsWith("--")) continue; // contract: CSS custom property keys start with "--" + d[kv.Key] = kv.Value; + } + + return d; + } + + /// + /// Structural value comparison used by to decide whether an + /// Animate target actually changed between renders. This avoids re-triggering an + /// animation (and OnAnimationStart) on every unrelated re-render when a consumer writes + /// the idiomatic Animate="@(new BmotionAnimationProps { ... })" (a fresh reference each render). + /// + internal bool ValueEquals(BmotionAnimationProps? o) + { + if (o is null) return false; + if (ReferenceEquals(this, o)) return true; + + bool scalars = + X == o.X && Y == o.Y && Z == o.Z && + Scale == o.Scale && ScaleX == o.ScaleX && ScaleY == o.ScaleY && + Rotate == o.Rotate && RotateX == o.RotateX && RotateY == o.RotateY && RotateZ == o.RotateZ && + SkewX == o.SkewX && SkewY == o.SkewY && Perspective == o.Perspective && + Opacity == o.Opacity && + BackgroundColor == o.BackgroundColor && Color == o.Color && BorderColor == o.BorderColor && + OutlineColor == o.OutlineColor && Fill == o.Fill && Stroke == o.Stroke && + Width == o.Width && Height == o.Height && BorderRadius == o.BorderRadius && BoxShadow == o.BoxShadow && + PathLength == o.PathLength && PathOffset == o.PathOffset && PathSpacing == o.PathSpacing; + + return scalars && DictEquals(CssVars, o.CssVars) && KeyframeDictEquals(Keyframes, o.Keyframes); + } + + private static bool DictEquals(Dictionary? a, Dictionary? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null || a.Count != b.Count) return false; + foreach (var kv in a) + if (!b.TryGetValue(kv.Key, out var v) || v != kv.Value) return false; + return true; + } + + private static bool KeyframeDictEquals(Dictionary? a, Dictionary? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null || a.Count != b.Count) return false; + foreach (var kv in a) + { + if (!b.TryGetValue(kv.Key, out var v)) return false; + if (!SequenceEquals(kv.Value, v)) return false; + } + return true; + } + + private static bool SequenceEquals(object? a, object? b) + { + if (Equals(a, b)) return true; + if (a is System.Collections.IEnumerable ea && a is not string && + b is System.Collections.IEnumerable eb && b is not string) + { + var ia = ea.GetEnumerator(); + var ib = eb.GetEnumerator(); + while (true) + { + bool na = ia.MoveNext(), nb = ib.MoveNext(); + if (na != nb) return false; + if (!na) return true; + if (!Equals(ia.Current, ib.Current)) return false; + } + } + return false; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs new file mode 100644 index 0000000000..13eab40d4b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionAnimationTarget.cs @@ -0,0 +1,49 @@ +namespace Bit.Bmotion; +/// +/// 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 BmotionAnimationTarget +{ + /// Direct set of animation properties. + public BmotionAnimationProps? Props { get; private init; } + + /// Name of a variant defined in the nearest Bmotion ancestor's Variants dictionary. + public string? Variant { get; private init; } + + /// When true this target is explicitly disabled (e.g. Initial="false"). + public bool IsDisabled { get; private init; } + + public bool HasProps => Props != null; + public bool IsVariant => Variant != null; + + /// + /// Value-based equivalence between two targets, used for change detection. + /// Two prop targets are equivalent when their values match; + /// two variant targets when they name the same variant. + /// + internal static bool AreEquivalent(BmotionAnimationTarget? a, BmotionAnimationTarget? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + if (a.IsDisabled || b.IsDisabled) return a.IsDisabled == b.IsDisabled; + if (a.IsVariant || b.IsVariant) + return string.Equals(a.Variant, b.Variant, StringComparison.OrdinalIgnoreCase); + if (a.Props is null || b.Props is null) return a.Props is null && b.Props is null; + return a.Props.ValueEquals(b.Props); + } + + // ── Implicit conversions ────────────────────────────────────────────────── + // Null inputs convert to a null target (not a target wrapping null) so downstream code can + // distinguish "no target set" from "target set to empty props" - e.g. the variant-fallback + // check in Bmotion only fires when Animate is genuinely null. + public static implicit operator BmotionAnimationTarget?(BmotionAnimationProps? props) + => props is null ? null : new() { Props = props }; + + public static implicit operator BmotionAnimationTarget?(string? variant) + => variant is null ? null : new() { Variant = variant }; + + public static implicit operator BmotionAnimationTarget(bool value) + => value ? new() { Props = new BmotionAnimationProps() } : new() { IsDisabled = true }; +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs new file mode 100644 index 0000000000..149d172ce3 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragAxis.cs @@ -0,0 +1,3 @@ +namespace Bit.Bmotion; + +public enum BmotionDragAxis { Both, X, Y } diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs new file mode 100644 index 0000000000..2b6680dbc5 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragConstraints.cs @@ -0,0 +1,28 @@ +namespace Bit.Bmotion; + +public class BmotionDragConstraints +{ + public double? Left { get; set; } + public double? Right { get; set; } + public double? Top { get; set; } + public double? Bottom { get; set; } + + public static BmotionDragConstraints Horizontal(double left, double right) + => new() { Left = left, Right = right }; + + public static BmotionDragConstraints Vertical(double top, double bottom) + => new() { Top = top, Bottom = bottom }; + + public static BmotionDragConstraints Box(double left, double right, double top, double bottom) + => new() { Left = left, Right = right, Top = top, Bottom = bottom }; + + internal object ToJsObject() + { + var d = new Dictionary(); + if (Left.HasValue) d["left"] = Left.Value; + if (Right.HasValue) d["right"] = Right.Value; + if (Top.HasValue) d["top"] = Top.Value; + if (Bottom.HasValue) d["bottom"] = Bottom.Value; + return d; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs new file mode 100644 index 0000000000..fcd497e8f9 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionDragOptions.cs @@ -0,0 +1,50 @@ +namespace Bit.Bmotion; +/// +/// Options for the drag gesture on a Bmotion element. +/// +public class BmotionDragOptions +{ + /// Restrict drag to a single axis. Defaults to . + public BmotionDragAxis Axis { get; set; } = BmotionDragAxis.Both; + + /// + /// Constraint bounds (in px relative to the element's resting position). + /// Null = unconstrained. + /// + public BmotionDragConstraints? Constraints { get; set; } + + /// + /// Elasticity when the drag exceeds constraints (0 = rigid, 1 = fully elastic). + /// Default: 0.35. Values are clamped to the [0, 1] range. + /// + public double Elastic + { + get => _elastic; + set => _elastic = Math.Clamp(value, 0, 1); + } + private double _elastic = 0.35; + + /// + /// Whether to apply momentum / inertia after releasing. Default: true. + /// + public bool Momentum { get; set; } = true; + + /// + /// Transition applied when snapping back to constraints after release. + /// Defaults to a spring. + /// + public BmotionTransitionConfig? SnapTransition { get; set; } + + /// + /// If true, the draggable element will spring back to its center (origin) when released. + /// Default: false. + /// + public bool SnapToOrigin { get; set; } + + /// + /// Locks drag to the dominant movement axis once detected. + /// For example, moving mostly horizontally will lock drag to x only. + /// Default: false. + /// + public bool DirectionLock { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs new file mode 100644 index 0000000000..232e75e022 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionEasing.cs @@ -0,0 +1,10 @@ +namespace Bit.Bmotion; + +public enum BmotionEasing +{ + Linear, + EaseIn, EaseOut, EaseInOut, + CircIn, CircOut, CircInOut, + BackIn, BackOut, BackInOut, + Anticipate +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs new file mode 100644 index 0000000000..a7944444ac --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionMotionVariants.cs @@ -0,0 +1,38 @@ +namespace Bit.Bmotion; +/// +/// A named set of animation states (variants) that can be referenced by name on +/// any Bmotion component. Children automatically inherit the active variant name +/// unless they define their own. +/// +public class BmotionMotionVariants +{ + private readonly Dictionary _variants = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a variant by name. If a variant with the same name already exists + /// (case-insensitive) it is replaced. + /// + public BmotionMotionVariants Add(string name, BmotionAnimationProps props) + { + ArgumentNullException.ThrowIfNull(props); + _variants[name] = props; + return this; + } + + public BmotionAnimationProps? Get(string name) + => _variants.TryGetValue(name, out var v) ? v : null; + + public bool Contains(string name) => _variants.ContainsKey(name); + + public BmotionAnimationProps? this[string name] => Get(name); + + // ── Builder shorthand ───────────────────────────────────────────────────── + public static BmotionMotionVariants Create(params (string name, BmotionAnimationProps props)[] entries) + { + ArgumentNullException.ThrowIfNull(entries); + var mv = new BmotionMotionVariants(); + foreach (var (name, props) in entries) + mv.Add(name, props); + return mv; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs new file mode 100644 index 0000000000..d037d0be0f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPanInfo.cs @@ -0,0 +1,20 @@ +namespace Bit.Bmotion; + +/// +/// Information about a pan gesture provided to OnPan callbacks. +/// Matches the Framer Motion pan event info shape. +/// +public class BmotionPanInfo +{ + /// Current pointer position relative to the document. + public required BmotionPointInfo Point { get; init; } + + /// Distance moved since the last event. + public required BmotionPointInfo Delta { get; init; } + + /// Total distance moved since the pan gesture started. + public required BmotionPointInfo Offset { get; init; } + + /// Current velocity of the pointer (pixels per second). + public required BmotionPointInfo Velocity { get; init; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs new file mode 100644 index 0000000000..aee0a99014 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionPointInfo.cs @@ -0,0 +1,8 @@ +namespace Bit.Bmotion; + +/// A 2-D point with and components. +public class BmotionPointInfo +{ + public double X { get; set; } + public double Y { get; set; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs new file mode 100644 index 0000000000..edbb15638b --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionRepeatType.cs @@ -0,0 +1,12 @@ +namespace Bit.Bmotion; + +/// How a repeating animation behaves on each subsequent iteration. +public enum BmotionRepeatType +{ + /// Restart from the beginning each iteration (0 → 1, 0 → 1, …). + Loop, + /// Ping-pong: alternate direction each iteration (0 → 1, 1 → 0, …). + Mirror, + /// Jump back to the start and play in reverse each iteration (1 → 0, 1 → 0, …). + Reverse, +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs new file mode 100644 index 0000000000..4d3dff38c1 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionScrollInfo.cs @@ -0,0 +1,29 @@ +namespace Bit.Bmotion; + +/// Data returned by the on each scroll event. +public class BmotionScrollInfo +{ + /// 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; } + + /// Total scrollable content width in pixels (including the part outside the viewport). + public double ScrollWidth { get; init; } + + /// Total scrollable content height in pixels (including the part outside the viewport). + public double ScrollHeight { get; init; } + + /// Visible viewport width in pixels (the currently displayed area). + public double ClientWidth { get; init; } + + /// Visible viewport height in pixels (the currently displayed area). + public double ClientHeight { get; init; } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs new file mode 100644 index 0000000000..9d947d860e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionConfig.cs @@ -0,0 +1,258 @@ +namespace Bit.Bmotion; +/// Controls how a value transitions from one state to another. +public class BmotionTransitionConfig +{ + // ── Type ───────────────────────────────────────────────────────────────── + /// Animation driver: Tween, Spring, or Inertia. Default: Tween. + public BmotionTransitionType Type { get; set; } = BmotionTransitionType.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 BmotionEasing Ease { get; set; } = BmotionEasing.EaseOut; + + /// + /// Custom cubic-bezier as [x1, y1, x2, y2]. Overrides when set. + /// Must be either null or an array of exactly 4 finite values. + /// + public double[]? EaseCubicBezier + { + get => _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone(); + set + { + var validated = ValidateCubicBezier(value); + _easeCubicBezier = validated is null ? null : (double[])validated.Clone(); + } + } + private double[]? _easeCubicBezier; + + private static double[]? ValidateCubicBezier(double[]? value) + { + if (value is null) return null; + if (value.Length != 4 || !value.All(double.IsFinite)) + throw new ArgumentException( + "EaseCubicBezier must be null or an array of exactly 4 finite values [x1, y1, x2, y2].", + nameof(value)); + // The control-point X coordinates must stay within [0, 1] so the bezier's x(t) curve is + // monotonic. Outside that range x(t) can fold back on itself, and the Newton-Raphson solver + // in BmotionEasingFunctions can then converge to the wrong root (a visibly broken easing). + if (value[0] is < 0 or > 1 || value[2] is < 0 or > 1) + throw new ArgumentException( + "EaseCubicBezier X coordinates (x1, x2) must be within [0, 1]; only Y may overshoot.", + nameof(value)); + return value; + } + + // ── Repeat ──────────────────────────────────────────────────────────────── + /// + /// Number of times to repeat. Setting (preferred) or the legacy + /// sentinel int.MaxValue makes the animation repeat forever. + /// + public int Repeat { get; set; } = 0; + + /// + /// When true the animation repeats forever, regardless of . + /// Prefer this over the legacy Repeat = int.MaxValue sentinel. + /// + public bool RepeatInfinite { get; set; } + + /// True when this transition repeats forever (via flag or legacy sentinel). + internal bool IsInfiniteRepeat => RepeatInfinite || Repeat == int.MaxValue; + + /// How to repeat: Loop, Mirror (ping-pong), or Reverse. + public BmotionRepeatType RepeatType { get; set; } = BmotionRepeatType.Loop; + + /// 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; } + + // ── Per-property overrides ──────────────────────────────────────────────── + /// + /// Override transition for specific properties, e.g. + /// Properties = new { ["opacity"] = new BmotionTransitionConfig { 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 ─────────────────────────────────────────────────────────────── + + /// + /// 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 BmotionTransitionConfig Clone() => new() + { + Type = Type, + Duration = Duration, + Delay = Delay, + Ease = Ease, + // Read the backing field directly: the EaseCubicBezier getter already returns a defensive + // clone, so cloning it again here would allocate a redundant second copy. + EaseCubicBezier = _easeCubicBezier is null ? null : (double[])_easeCubicBezier.Clone(), + Repeat = Repeat, + RepeatInfinite = RepeatInfinite, + RepeatType = RepeatType, + 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, + Properties = CloneProperties(Properties), + OnUpdate = OnUpdate, + }; + + private static Dictionary? CloneProperties( + Dictionary? source) + { + if (source is null) return null; + // Preserve the source's key comparer so a custom (e.g. case-insensitive) lookup semantic + // survives the clone instead of silently reverting to the default ordinal comparer. + var copy = new Dictionary(source.Count, source.Comparer); + foreach (var kv in source) + { + // The dictionary's value type is non-nullable, so a null per-property override violates + // the contract and would surface as a NullReferenceException downstream. Reject it here + // with a clear message instead of propagating the null via the null-forgiving operator. + if (kv.Value is null) + throw new ArgumentException( + $"Per-property transition override for '{kv.Key}' must not be null.", nameof(source)); + copy[kv.Key] = kv.Value.Clone(); + } + return copy; + } + + // ── Factory helpers ─────────────────────────────────────────────────────── + public static BmotionTransitionConfig Spring(double stiffness = 100, double damping = 10, double mass = 1) + => new() { Type = BmotionTransitionType.Spring, Stiffness = stiffness, Damping = damping, Mass = mass }; + + /// + /// Duration-based spring using intuitive (0 = no bounce, 1 = very bouncy) + /// and parameters. Stiffness and damping are derived automatically. + /// + public static BmotionTransitionConfig BounceSpring(double duration = 0.5, double bounce = 0.25, double mass = 1) + { + var (stiffness, damping) = SpringFromBounce(duration, bounce, mass); + return new() + { + Type = BmotionTransitionType.Spring, + Duration = duration, + Bounce = bounce, + VisualDuration = duration, + Stiffness = stiffness, + Damping = damping, + Mass = mass, + }; + } + + public static BmotionTransitionConfig Tween(double duration = 0.3, BmotionEasing ease = BmotionEasing.EaseOut) + => new() { Type = BmotionTransitionType.Tween, Duration = duration, Ease = ease }; + + public static BmotionTransitionConfig Inertia(double velocity = 0, double timeConstant = 700) + => new() { Type = BmotionTransitionType.Inertia, InertiaVelocity = velocity, TimeConstant = timeConstant }; + + /// + /// Derives (stiffness, damping) from Framer-Motion-compatible + /// (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)); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs new file mode 100644 index 0000000000..3492bcf741 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionTransitionType.cs @@ -0,0 +1,14 @@ +namespace Bit.Bmotion; + +/// The animation driver (physics or interpolation model) used for a transition. +public enum BmotionTransitionType +{ + /// Duration- and easing-based interpolation between start and end values. + Tween, + /// Physics-based spring driven by stiffness, damping and mass. + Spring, + /// Velocity-based deceleration that coasts to a stop (e.g. momentum after a drag). + Inertia, + /// Interpolation across an array of keyframe values rather than a single target. + Keyframes, +} diff --git a/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs new file mode 100644 index 0000000000..b76ebbc6aa --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Models/BmotionViewportOptions.cs @@ -0,0 +1,52 @@ +namespace Bit.Bmotion; +/// +/// Options that control how a element is tracked within the viewport +/// for WhileInView and OnViewportEnter/OnViewportLeave animations. +/// +public class BmotionViewportOptions +{ + /// + /// 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() + { + var amount = Amount?.Trim().ToLowerInvariant(); + double threshold = amount switch + { + "some" => 0.0, + "all" => 1.0, + _ => double.TryParse(amount, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v) && double.IsFinite(v) + ? Math.Clamp(v, 0, 1) : 0.0, + }; + + return new Dictionary + { + ["once"] = Once, + // Fall back to "0px" for null/whitespace so the JS side always receives a valid + // IntersectionObserver rootMargin string instead of an empty/invalid value. + ["margin"] = string.IsNullOrWhiteSpace(Margin) ? "0px" : Margin, + ["threshold"] = threshold, + }; + } +} diff --git a/src/Bmotion/Bit.Bmotion/README.md b/src/Bmotion/Bit.Bmotion/README.md new file mode 100644 index 0000000000..db38c850f8 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/README.md @@ -0,0 +1,67 @@ +# Bit.Bmotion + +A Framer-Motion-style animation library for Blazor. All animation math (spring, tween, +inertia, keyframes, easing, color interpolation, gesture state, transform composition) runs +in C#; JavaScript is used only as a thin bridge to browser-native APIs. + +## ⚠️ Platform support: Blazor WebAssembly only + +Bit.Bmotion drives its animation loop over **synchronous** JS↔.NET interop. That is only +available on **Blazor WebAssembly**, so: + +- ✅ **Blazor WebAssembly** — fully supported. +- ❌ **Blazor Server** — not supported (synchronous interop is unavailable). Starting the + animation loop throws `PlatformNotSupportedException`. +- ⏸️ **Server-side prerendering** — components render their initial styles, but animations + do not start until the WebAssembly runtime becomes interactive. + +## Getting started + +```csharp +// Program.cs (WebAssembly host) +builder.Services.AddBitBmotionServices(); +``` + +```razor + +``` + +See the XML documentation on `Bmotion`, `BmotionAnimateService`, and `BmotionTransitionConfig` +for the full API surface. + +## Accessibility: reduced motion is opt-in + +`prefers-reduced-motion` is **only** honoured for elements inside a ``. Elements +with no surrounding config always animate, even when the OS requests reduced motion. This is a +deliberate choice (so the OS setting never silently disables animations an app didn't opt into), +but it is the opposite of the web-platform default — if you care about reduced-motion +accessibility, wrap your app (or the relevant subtree) in a config: + +```razor + @* ReduceMotion defaults to null = follow the OS setting *@ + ... + +``` + +Set `ReduceMotion="true"`/`"false"` to force it on or off regardless of the OS preference. + +## Security note + +String-valued animation properties (`BackgroundColor`, `Width`, `BoxShadow`, `Color`, custom +`CssVars`, …) are written verbatim into the element's inline style. They are meant for +developer-authored values. Do **not** bind untrusted end-user input to them, or you risk CSS +injection into the element's `style`. + +## Threading + +The animation engine is intentionally lock-free and assumes a single (the WebAssembly UI) thread. +Do **not** enable WebAssembly multithreading (``) with this library. + +## Disposing the programmatic helpers + +`BmotionAnimationController` and `BmotionScrollTracker` are registered `Transient`. When obtained +via `@inject`, Blazor does **not** dispose them per component — the consuming component must +dispose them itself (from its own `Dispose`/`DisposeAsync`), otherwise their engine registration / +JS subscription leaks until the app shuts down. diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs new file mode 100644 index 0000000000..f469d1d8ba --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimateService.cs @@ -0,0 +1,142 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Bmotion; +/// +/// Provides a method-based animation API analogous to the animate() function in +/// motion.dev. +/// +/// Elements are identified by a CSS selector string or a Blazor . +/// They do not need to be wrapped in a <Bmotion> component. +/// +/// +/// +/// +/// // By CSS selector +/// var controls = await Motion.AnimateAsync(".box", new BmotionAnimationProps { X = 100, Opacity = 1 }); +/// await controls; // wait for completion +/// +/// // By ElementReference captured via @ref +/// var controls = await Motion.AnimateAsync(myRef, new BmotionAnimationProps { Scale = 1.2 }, +/// BmotionTransitionConfig.Spring()); +/// controls.Stop(); // cancel early +/// +/// +public sealed class BmotionAnimateService +{ + private readonly BmotionAnimationEngine _engine; + private readonly BmotionInterop _interop; + + public BmotionAnimateService(BmotionAnimationEngine engine, BmotionInterop interop) + { + ArgumentNullException.ThrowIfNull(engine); + ArgumentNullException.ThrowIfNull(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, + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition = null) + { + if (string.IsNullOrWhiteSpace(selector)) + throw new ArgumentException("Selector must not be null or whitespace.", nameof(selector)); + ArgumentNullException.ThrowIfNull(keyframes); + 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, + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition = null) + { + ArgumentNullException.ThrowIfNull(keyframes); + var id = await _interop.ResolveOrRegisterByRefAsync(elementReference); + return StartAnimations([id], keyframes, transition); + } + + // ──────────────────────────────────────────────────────────────────────────── + + private BmotionAnimationControls StartAnimations( + string[] elementIds, + BmotionAnimationProps keyframes, + BmotionTransitionConfig? transition) + { + var values = keyframes.ToJsDictionary(); + + // Reference-count every target for the lifetime of this call. Overlapping AnimateAsync + // invocations (and any wrapping ) each hold a count, so an element is only torn + // down once the last animation settles - a single completing call can't unregister an + // element out from under another still-running animation. + foreach (var id in elementIds) + _engine.RegisterElement(id); + + // Start all animations concurrently; collect their completion tasks. If task creation + // throws synchronously (e.g. a driver rejects the keyframes), release every element we + // already registered so they don't leak a refcount before the exception propagates. + Task[] completionTasks; + try + { + completionTasks = elementIds + .Select(id => _engine.AnimateToAwaitAsync(id, values, transition).AsTask()) + .ToArray(); + } + catch + { + foreach (var id in elementIds) + _engine.UnregisterElement(id); + throw; + } + + var completion = Task.WhenAll(completionTasks); + + // The controls own the single refcount release (idempotent). It fires whichever happens + // first: natural completion, Stop(), or Complete(). This is what prevents an + // infinite-repeat animation - whose completion task never resolves - from leaking refcounts. + var released = false; + void Release() + { + if (released) return; + released = true; + foreach (var id in elementIds) + _engine.UnregisterElement(id); + } + + var controls = new BmotionAnimationControls(elementIds, _engine, completion, Release); + + _ = completion.ContinueWith( + _ => controls.OnCompletionSettled(), + TaskScheduler.Default); + + return controls; + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs new file mode 100644 index 0000000000..8615f031b2 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationController.cs @@ -0,0 +1,85 @@ + +namespace Bit.Bmotion; +/// +/// Programmatic animation controller. +/// Analogous to Framer Motion's useAnimate(). +/// Obtain via DI (@inject BmotionAnimationController) and bind to an element ID. +/// All animation math runs in the C# . +/// +/// Lifetime / disposal: registered Transient and meant to be owned by a single +/// component. When injected with @inject, Blazor resolves it from the root scope and only +/// disposes it at app shutdown, so the consuming component must dispose it explicitly +/// (implement and call from the component's +/// Dispose) - otherwise the bound element stays registered with the engine until the app ends. +/// +/// +public sealed class BmotionAnimationController : IDisposable +{ + private readonly BmotionAnimationEngine _engine; + private string? _elementId; + + public BmotionAnimationController(BmotionAnimationEngine engine) + { + ArgumentNullException.ThrowIfNull(engine); + _engine = engine; + } + + /// + /// Bind by element ID. Ensures the element is registered with the engine so the controller + /// works even when the target isn't wrapped in a <Bmotion> component. + /// + public void BindTo(string elementId) + { + if (string.IsNullOrWhiteSpace(elementId)) + throw new ArgumentException("Element ID must not be null or whitespace.", nameof(elementId)); + // Already bound to this element: avoid re-registering (which would unbalance the engine refcount). + if (_elementId == elementId) return; + // Release the previously bound element so repeated BindTo calls don't leak engine state. + if (!string.IsNullOrEmpty(_elementId) && _elementId != elementId) + _engine.UnregisterElement(_elementId); + _elementId = elementId; + _engine.RegisterElement(elementId); + } + + /// Animate the bound element to the given props (fire-and-forget). + public async ValueTask AnimateAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) + { + ArgumentNullException.ThrowIfNull(props); + if (_elementId == null) return; + await _engine.AnimateToAsync(_elementId, props.ToJsDictionary(), transition); + } + + /// Animate and await completion. + public async ValueTask AnimateAwaitAsync(BmotionAnimationProps props, BmotionTransitionConfig? transition = null) + { + ArgumentNullException.ThrowIfNull(props); + if (_elementId == null) return; + await _engine.AnimateToAwaitAsync(_elementId, props.ToJsDictionary(), transition); + } + + /// Instantly set props without animation. + public void Set(BmotionAnimationProps props) + { + ArgumentNullException.ThrowIfNull(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; + var props = properties == null || properties.Length == 0 ? null : properties; + _engine.Stop(_elementId, props); + } + + /// Unregister the bound element from the engine when the controller is disposed. + public void Dispose() + { + if (!string.IsNullOrEmpty(_elementId)) + { + _engine.UnregisterElement(_elementId); + _elementId = null; + } + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs new file mode 100644 index 0000000000..9076772e9e --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionAnimationControls.cs @@ -0,0 +1,89 @@ +using System.Runtime.CompilerServices; + +namespace Bit.Bmotion; +/// +/// Controls for an in-flight programmatic animation started by +/// . +/// The object is directly awaitable - await controls waits for the animation to complete. +/// +/// freezes the animation at its current (intermediate) values; +/// jumps it to its target (end) values. Both release the engine refcount immediately, so they also +/// safely stop infinite-repeat animations (whose completion task never resolves on its own). +/// +/// +public sealed class BmotionAnimationControls +{ + private readonly IReadOnlyList _elementIds; + private readonly BmotionAnimationEngine _engine; + private readonly Task _completion; + private readonly Action _release; + private int _released; + + internal BmotionAnimationControls( + IReadOnlyList elementIds, BmotionAnimationEngine engine, Task completion, Action release) + { + _elementIds = elementIds; + _engine = engine; + _completion = completion; + _release = release; + } + + // Release the engine refcount exactly once - whether the animation finishes naturally, is + // stopped, or is completed early. Without this, an infinite-repeat animation (which never + // finishes) would pin its elements in the engine forever. + private void ReleaseOnce() + { + if (System.Threading.Interlocked.Exchange(ref _released, 1) == 0) + _release(); + } + + /// + /// Immediately cancel all running animations on the target elements. + /// Elements snap to their current (intermediate) positions. + /// + public void Stop() + { + // Atomically claim ownership: only the caller that flips _released from 0→1 runs the engine + // side effects. Once released (e.g. a natural finish already settled the completion), the + // target elements may be owned by newer animations - skip so we don't disturb them. + if (System.Threading.Interlocked.CompareExchange(ref _released, 1, 0) != 0) return; + try + { + foreach (var id in _elementIds) + _engine.Stop(id, null); + } + finally + { + _release(); + } + } + + /// + /// Cancel all running animations and snap elements to their target (end) values. + /// + public void Complete() + { + // See Stop(): atomically claim ownership so engine side effects run exactly once and never + // after a concurrent settlement has handed the elements to newer animations. + if (System.Threading.Interlocked.CompareExchange(ref _released, 1, 0) != 0) return; + try + { + foreach (var id in _elementIds) + _engine.Complete(id); + } + finally + { + _release(); + } + } + + /// A that resolves when all animations finish naturally. + public Task WhenCompleteAsync() => _completion; + + /// Makes directly awaitable. + public TaskAwaiter GetAwaiter() => _completion.GetAwaiter(); + + // Invoked by the owning service once the completion task settles, so a natural finish also + // releases the refcount (idempotent with Stop/Complete). + internal void OnCompletionSettled() => ReleaseOnce(); +} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs new file mode 100644 index 0000000000..c20a64bbbb --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionScrollTracker.cs @@ -0,0 +1,126 @@ +using Microsoft.JSInterop; + +namespace Bit.Bmotion; +/// +/// Tracks scroll progress (0–1) for a container element or the window. +/// Analogous to Framer Motion's useScroll. +/// +/// +/// Lifetime / disposal: this service is registered Transient and is meant to be +/// owned by a single component. When injected with @inject, Blazor resolves it from the +/// root scope and only disposes it at app shutdown - so the consuming component must dispose it +/// explicitly (implement and call in +/// the component's DisposeAsync), otherwise its JS scroll subscription and +/// DotNetObjectReference leak until the app ends. The reference is created lazily, so an +/// injected-but-unused tracker holds no JS resources. +/// +/// +/// Usage: +/// +/// @implements IAsyncDisposable +/// @inject BmotionScrollTracker Scroll +/// +/// protected override async Task OnAfterRenderAsync(bool firstRender) +/// { +/// if (firstRender) await Scroll.ObserveAsync(null, info => scrollY = info.ProgressY); +/// } +/// +/// public ValueTask DisposeAsync() => Scroll.DisposeAsync(); +/// +/// +public sealed class BmotionScrollTracker : IAsyncDisposable +{ + private readonly BmotionInterop _interop; + private readonly List _subscriptionKeys = new(); + private DotNetObjectReference? _dotnet; + + private Func? _onScroll; + private bool _disposed; + + public BmotionScrollTracker(BmotionInterop interop) + { + ArgumentNullException.ThrowIfNull(interop); + _interop = interop; + } + + // Created on first use so an injected-but-unused tracker doesn't allocate a JS-object reference. + private DotNetObjectReference Dotnet + => _dotnet ??= DotNetObjectReference.Create(this); + /// Horizontal scroll progress 0–1. + public double ProgressX { get; private set; } + + /// 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); + ArgumentNullException.ThrowIfNull(onChange); + // Remove any existing subscription so only one stays active. + foreach (var existing in _subscriptionKeys) + await _interop.UnobserveScrollAsync(existing); + _subscriptionKeys.Clear(); + + _onScroll = onChange; + var key = await _interop.ObserveScrollAsync(containerId, Dotnet); + if (key != null) _subscriptionKeys.Add(key); + } + + /// Synchronous overload. + public Task ObserveAsync(string? containerId, Action onChange) + { + ArgumentNullException.ThrowIfNull(onChange); + return ObserveAsync(containerId, info => { onChange(info); return Task.CompletedTask; }); + } + + // ── JS → C# callback ───────────────────────────────────────────────────── + + [JSInvokable] + public async Task OnScroll(BmotionScrollInfo info) + { + // info crosses the JS→C# boundary; guard against a null payload so the property reads below + // don't throw a NullReferenceException inside the interop callback. + if (info is null) return; + ProgressX = info.ProgressX; + ProgressY = info.ProgressY; + ScrollX = info.ScrollX; + ScrollY = info.ScrollY; + if (_onScroll != null) + { + // Guard the user callback so a faulting handler can't fault the JS-invokable flow + // (which would destabilise the interop bridge / host circuit). + try { await _onScroll(info); } + catch { /* swallow user-callback failures to keep the scroll bridge alive */ } + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + try + { + foreach (var key in _subscriptionKeys) + await _interop.UnobserveScrollAsync(key); + } + finally + { + // Always release local resources, even if a JS unobserve call faults during teardown, + // so the DotNetObjectReference and callback don't leak. + _subscriptionKeys.Clear(); + _onScroll = null; + _dotnet?.Dispose(); + // Note: BmotionInterop itself is DI-scoped and disposed by the DI container + } + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs new file mode 100644 index 0000000000..2e30baf3a6 --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValue.cs @@ -0,0 +1,183 @@ +namespace Bit.Bmotion; +/// +/// A reactive numeric value whose changes can be observed and linked to animations. +/// Analogous to Framer Motion's MotionValue<T>. +/// Purely C# - no JS synchronisation required. +/// +public class BmotionValue : IDisposable where T : struct +{ + private readonly string _id; + private T _value; + private readonly List> _subscribers = new(); + + /// Numeric value types accepted by the range-mapping Transform overload. + private static readonly HashSet _numericTypes = new() + { + typeof(byte), typeof(sbyte), typeof(short), typeof(ushort), + typeof(int), typeof(uint), typeof(long), typeof(ulong), + typeof(float), typeof(double), typeof(decimal), + }; + + /// Subscription to a parent BmotionValue when this instance is a derived/transformed value. + private IDisposable? _upstream; + + internal BmotionValue(string id, T initial) + { + _id = id; + _value = initial; + } + + // ── Value access ────────────────────────────────────────────────────────── + + public T Value + { + get => _value; + set => SetSync(value); + } + + /// + /// Synchronously updates the value and notifies subscribers. Subscriber tasks are + /// observed (rather than dropped) so their exceptions don't go unobserved. + /// + public void SetSync(T value) + { + _value = value; + foreach (var sub in _subscribers.ToArray()) + { + // Guard the invocation itself: a subscriber may throw synchronously before returning a + // Task. Catch so one faulty subscriber can't skip the rest of the chain. + try { _ = ObserveAsync(sub(value)); } + catch { /* subscriber failures are swallowed to avoid faulting the host */ } + } + } + + private static async Task ObserveAsync(Task task) + { + try { await task; } + catch { /* subscriber failures are swallowed to avoid faulting the host */ } + } + + /// Update the value and notify all subscribers. + public async Task SetAsync(T value) + { + _value = value; + foreach (var sub in _subscribers.ToArray()) + { + // Catch both synchronous throws and faulted tasks so a single failing subscriber + // doesn't prevent the remaining subscribers from being notified. + try { await sub(value); } + catch { /* subscriber failures are swallowed to avoid faulting the host */ } + } + } + + // ── Subscriptions ───────────────────────────────────────────────────────── + + /// Subscribe to value changes. Returns an unsubscribe action. + public IDisposable Subscribe(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + _subscribers.Add(callback); + return new Subscription(() => _subscribers.Remove(callback)); + } + + /// Synchronous convenience overload. + public IDisposable Subscribe(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + return Subscribe(v => { callback(v); return Task.CompletedTask; }); + } + + // ── Transforms ──────────────────────────────────────────────────────────── + + /// + /// Create a derived BmotionValue that applies a transformation function. + /// Analogous to Framer Motion's useTransform. + /// + public BmotionValue Transform(Func fn) where TOut : struct + { + ArgumentNullException.ThrowIfNull(fn); + var derived = new BmotionValue($"{_id}_t", fn(_value)); + // Subscribe weakly: the parent must not keep the derived value alive (that would make the + // parent→derived link a leak for callers that drop the derived). The derived keeps the + // parent alive via _upstream for as long as the caller holds the derived, which is the + // intended direction. The subscription self-removes once the derived is collected. + derived._upstream = SubscribeWeak(derived, fn); + return derived; + } + + /// + /// Map from an input range to an output range using linear interpolation. + /// + public BmotionValue Transform(double[] inputRange, double[] outputRange) + { + ArgumentNullException.ThrowIfNull(inputRange); + ArgumentNullException.ThrowIfNull(outputRange); + if (!_numericTypes.Contains(typeof(T))) + throw new ArgumentException( + $"Transform(inputRange, outputRange) only supports numeric value types; '{typeof(T).Name}' is not numeric."); + if (inputRange.Length != outputRange.Length) + throw new ArgumentException("inputRange and outputRange must have the same length."); + if (inputRange.Length < 2) + throw new ArgumentException("inputRange and outputRange must contain at least 2 points."); + for (int i = 0; i < inputRange.Length - 1; i++) + if (inputRange[i + 1] <= inputRange[i]) + throw new ArgumentException("inputRange must be strictly increasing (no repeated or decreasing points)."); + + // Snapshot the ranges so the Map closure isn't affected by the caller mutating the + // passed-in arrays after this method returns (which would bypass the validation above). + var inRange = (double[])inputRange.Clone(); + var outRange = (double[])outputRange.Clone(); + + double Map(T v) + { + double x = Convert.ToDouble(v); + for (int i = 0; i < inRange.Length - 1; i++) + { + if (x >= inRange[i] && x <= inRange[i + 1]) + { + double t = (x - inRange[i]) / (inRange[i + 1] - inRange[i]); + return outRange[i] + t * (outRange[i + 1] - outRange[i]); + } + } + return x < inRange[0] ? outRange[0] : outRange[^1]; + } + + var derived = new BmotionValue($"{_id}_tr", Map(_value)); + derived._upstream = SubscribeWeak(derived, Map); + return derived; + } + + /// + /// Subscribes the value to this value's changes through a weak + /// reference, so this (parent) value never keeps the derived one alive. The subscription + /// removes itself the first time it fires after the derived value has been collected. + /// + private IDisposable SubscribeWeak(BmotionValue derived, Func project) + where TOut : struct + { + var weak = new WeakReference>(derived); + IDisposable? sub = null; + sub = Subscribe(async v => + { + if (weak.TryGetTarget(out var target)) + await target.SetAsync(project(v)); + else + sub?.Dispose(); // derived value collected - drop the dead subscription + }); + return sub; + } + + public void Dispose() + { + _upstream?.Dispose(); + _upstream = null; + _subscribers.Clear(); + } + + private sealed class Subscription : IDisposable + { + private readonly Action _dispose; + public Subscription(Action dispose) => _dispose = dispose; + public void Dispose() => _dispose(); + } +} diff --git a/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs new file mode 100644 index 0000000000..125b0c31df --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/Services/BmotionValueFactory.cs @@ -0,0 +1,8 @@ +namespace Bit.Bmotion; + +/// Factory helper for creating BmotionValues. +public static class BmotionValueFactory +{ + public static BmotionValue Create(T initial) where T : struct + => new($"mv_{Guid.NewGuid():N}", initial); +} diff --git a/src/Bmotion/Bit.Bmotion/_Imports.razor b/src/Bmotion/Bit.Bmotion/_Imports.razor new file mode 100644 index 0000000000..841dccaa9f --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/_Imports.razor @@ -0,0 +1,2 @@ +@using Microsoft.AspNetCore.Components.Web +@using Bit.Bmotion diff --git a/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js new file mode 100644 index 0000000000..0708554e8d --- /dev/null +++ b/src/Bmotion/Bit.Bmotion/wwwroot/bit-bmotion.js @@ -0,0 +1,542 @@ +/** + * bit-bmotion.js - slim browser-API bridge + * + * All animation math (spring, tween, inertia, keyframes, easing, colour + * interpolation, gesture state, transform composition) now lives in the + * 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; +// Set of engine DotNetObjectReferences. Using a set (rather than a single global) means +// multiple Blazor roots / engine instances sharing this module each get ticked, instead of a +// second startRafLoop silently hijacking the loop from the first. +const _engines = new Set(); + +export function startRafLoop(dotnetRef) { + _engines.add(dotnetRef); + if (_rafId === null) _rafId = requestAnimationFrame(_tick); +} + +export function stopRafLoop(dotnetRef) { + // With an argument, stop only that engine; without one, stop everything (back-compat). + if (dotnetRef) _engines.delete(dotnetRef); + else _engines.clear(); + if (_engines.size === 0 && _rafId !== null) { + cancelAnimationFrame(_rafId); + _rafId = null; + } +} + +function _tick(timestamp) { + if (_engines.size === 0) { _rafId = null; return; } + for (const ref of _engines) { + try { + // invokeMethod is synchronous in Blazor WASM C# does all animation math here + const updates = ref.invokeMethod('ComputeFrame', timestamp); + if (updates) { + for (const elementId in updates) { + const el = document.getElementById(elementId); + if (!el) continue; + _applyStyles(el, updates[elementId]); + } + } + } catch (e) { + // Never let a fault from one engine's synchronous tick stop the shared rAF loop. + console.error('bmotion: ComputeFrame tick failed', e); + } + } + _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); +} + +// +// 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; +} + +// Live prefers-reduced-motion change notifications. Keyed by engine DotNetObjectReference so +// each engine can subscribe/unsubscribe independently and we only keep one media-query listener. +const _reducedMotionRefs = new Set(); +let _reducedMotionMql = null; +let _reducedMotionListener = null; + +function _ensureReducedMotionListener() { + if (_reducedMotionMql || typeof matchMedia !== 'function') return; + _reducedMotionMql = matchMedia('(prefers-reduced-motion: reduce)'); + _reducedMotionListener = (e) => { + for (const ref of _reducedMotionRefs) { + try { ref.invokeMethodAsync('OnReducedMotionChanged', e.matches); } + catch { /* a disposed/faulted engine ref must not break the others */ } + } + }; + // addEventListener is the modern API; addListener is the deprecated Safari fallback. + if (_reducedMotionMql.addEventListener) _reducedMotionMql.addEventListener('change', _reducedMotionListener); + else if (_reducedMotionMql.addListener) _reducedMotionMql.addListener(_reducedMotionListener); +} + +export function watchReducedMotion(dotnetRef) { + _reducedMotionRefs.add(dotnetRef); + _ensureReducedMotionListener(); +} + +export function unwatchReducedMotion(dotnetRef) { + _reducedMotionRefs.delete(dotnetRef); + if (_reducedMotionRefs.size === 0 && _reducedMotionMql && _reducedMotionListener) { + if (_reducedMotionMql.removeEventListener) _reducedMotionMql.removeEventListener('change', _reducedMotionListener); + else if (_reducedMotionMql.removeListener) _reducedMotionMql.removeListener(_reducedMotionListener); + _reducedMotionMql = null; + _reducedMotionListener = null; + } +} + +// +// Element registration +// + +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); + // Detach from every viewport observer (drops membership and evicts empty observers). + _detachFromObservers(el, elementId); + _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 = (e) => { + if (e.button !== 0 && e.pointerType !== 'touch') return; // primary button / touch only + pressing = true; dotnetRef.invokeMethodAsync('OnPointerDown'); + }; + const onUp = (e) => { + if (e.button !== 0 && e.pointerType !== 'touch') return; // ignore non-primary releases + 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) { + // When drag is also active it already calls setPointerCapture; let pan reuse that capture + // instead of grabbing the pointer a second time for the same element. + _attachPan(el, dotnetRef, cleanups, !!events.drag); + } + + // Drag + if (events.drag) { + _attachDrag(elementId, el, events, dotnetRef, cleanups); + } +} + +function _attachPan(el, dotnetRef, cleanups, skipCapture) { + const PAN_THRESHOLD = 3; // pixels before pan is detected + let down = false; // whether a pointer is currently pressed on this element + let panning = false; + let startX, startY, lastX, lastY, lastT; + let velX = 0, velY = 0; + + const onDown = (e) => { + if (e.button !== 0 && e.pointerType !== 'touch') return; + down = true; + startX = lastX = e.clientX; startY = lastY = e.clientY; + lastT = performance.now(); velX = velY = 0; panning = false; + // Skip when drag already owns the pointer capture for this element. + if (!skipCapture) el.setPointerCapture(e.pointerId); + }; + + const onMove = (e) => { + // Ignore moves when no pointer is pressed (e.g. plain hover) so stale start + // coordinates from a previous gesture can't trigger a phantom pan. + if (!down) return; + const dx = e.clientX - startX, dy = e.clientY - startY; + const now = performance.now(), dt = now - lastT; + const deltaX = e.clientX - lastX, deltaY = e.clientY - lastY; + if (dt > 0) { + velX = deltaX / dt * 1000; + velY = deltaY / dt * 1000; + } + + if (!panning && Math.sqrt(dx * dx + dy * dy) >= PAN_THRESHOLD) { + panning = true; + dotnetRef.invokeMethodAsync('OnPanStart_'); + } + if (panning) { + dotnetRef.invokeMethodAsync('OnPanMove', + e.clientX, e.clientY, + deltaX, deltaY, + dx, dy, + velX, velY); + } + + lastX = e.clientX; lastY = e.clientY; lastT = now; + }; + + const onUp = () => { down = false; 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) { + // Velocity is sampled per pointer-move as px/ms and scaled to "px per frame" (~16ms) so the + // C# inertia driver receives a frame-relative figure consistent with its release-velocity math. + const FRAME_MS = 16; + 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 = performance.now(); + velX = velY = 0; + dragging = true; + lockedAxis = null; + el.setPointerCapture(e.pointerId); + dotnetRef.invokeMethodAsync('OnPointerDown_Drag'); + }; + + const onMove = (e) => { + if (!dragging) return; + const now = performance.now(), dt = now - lastT; + if (dt > 0) { velX = (e.clientX - lastPX) / dt * FRAME_MS; velY = (e.clientY - lastPY) / dt * FRAME_MS; } + lastPX = e.clientX; lastPY = e.clientY; lastT = now; + + // 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. Each entry tracks the +// element IDs it currently observes so the observer can be disconnected once it falls empty +// (otherwise distinct margin/threshold combinations would accumulate observers forever). +const _vpObservers = new Map(); // sig → { observer, members: Set } +const _vpRefs = new Map(); // elementId → { dotnetRef, once } + +function _vpSig(margin, threshold) { return `${margin}|${threshold}`; } + +function _getVpEntry(margin, threshold) { + const sig = _vpSig(margin, threshold); + let entry = _vpObservers.get(sig); + if (entry) return entry; + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute('data-bmid'); + const ref = _vpRefs.get(id); + if (!ref) continue; + ref.dotnetRef.invokeMethodAsync('OnIntersect', entry.isIntersecting); + if (ref.once && entry.isIntersecting) { + _detachFromObservers(entry.target, id); + _vpRefs.delete(id); + } + } + }, { rootMargin: margin || '0px', threshold: threshold ?? 0 }); + entry = { observer, members: new Set() }; + _vpObservers.set(sig, entry); + return entry; +} + +// Unobserve an element from every observer that might track it, dropping membership and +// disconnecting (and evicting) any observer left with no members. +function _detachFromObservers(el, elementId) { + for (const [sig, entry] of _vpObservers) { + if (el) entry.observer.unobserve(el); + entry.members.delete(elementId); + if (entry.members.size === 0) { + entry.observer.disconnect(); + _vpObservers.delete(sig); + } + } +} + +export function observeViewport(elementId, dotnetRef, options) { + const el = document.getElementById(elementId); + if (!el) return; + const once = options?.once ?? false; + const margin = options?.margin ?? '0px'; + const threshold = options?.threshold ?? 0; + // Detach from any previously assigned observer first so re-observing with different options + // doesn't stack duplicate subscriptions (which would fire OnIntersect multiple times and + // break the "once" behaviour across option changes). + _detachFromObservers(el, elementId); + _vpRefs.set(elementId, { dotnetRef, once }); + const entry = _getVpEntry(margin, threshold); + entry.members.add(elementId); + entry.observer.observe(el); +} + +export function unobserveViewport(elementId) { + const el = document.getElementById(elementId); + _detachFromObservers(el, elementId); + _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); +} diff --git a/src/Bmotion/README.md b/src/Bmotion/README.md new file mode 100644 index 0000000000..5c434640a0 --- /dev/null +++ b/src/Bmotion/README.md @@ -0,0 +1,387 @@ +# Bit.Bmotion + +A Blazor-native animation library inspired by [Framer Motion](https://www.framer.com/motion/). Springs, gestures, layout animations, variants, and keyframes - **no manual JavaScript wiring required**. All animation math runs in C# via WebAssembly; the slim browser bridge is auto-loaded for you. + +> Targets **.NET 8, 9, and 10** + +--- + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Components](#components) + - [Bmotion](#bmotion) + - [BmotionAnimatePresence](#bmotionanimatepresence) + - [BmotionConfig](#bmotionconfig) +- [Animation Models](#animation-models) + - [BmotionAnimationProps](#bmotionanimationprops) + - [BmotionTransitionConfig](#bmotiontransitionconfig) + - [BmotionMotionVariants](#bmotionmotionvariants) + - [BmotionDragOptions](#bmotiondragoptions) + - [BmotionViewportOptions](#bmotionviewportoptions) +- [Services](#services) + - [BmotionAnimationController](#bmotionanimationcontroller) + - [BmotionAnimateService](#bmotionanimateservice) + - [BmotionValue](#bmotionvalue) +- [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 (`bit-bmotion.js`) ships as a static web asset of the package and is +imported automatically the first time an animation runs, so no manual `