use leptos::prelude::*;
use leptos_motion_core::*;
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use wasm_bindgen_test::{console_error, console_log};
use web_sys::{CssStyleDeclaration, DomRect, Element};
#[derive(Clone, Debug)]
pub struct FLIPConfig {
pub duration: f64,
pub ease: Easing,
pub animate_scale: bool,
pub animate_rotation: bool,
pub animate_opacity: bool,
pub z_index: i32,
}
impl Default for FLIPConfig {
fn default() -> Self {
Self {
duration: 0.3,
ease: Easing::EaseOut,
animate_scale: true,
animate_rotation: false,
animate_opacity: false,
z_index: 1000,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum FLIPState {
First,
Last,
Inverted,
Playing,
Completed,
}
#[derive(Clone, Debug)]
pub struct LayoutInfo {
pub rect: DomRect,
pub transform: String,
pub opacity: f64,
pub scale: (f64, f64),
pub rotation: f64,
}
impl LayoutInfo {
pub fn from_element(element: &WebElement) -> std::result::Result<Self, JsValue> {
let rect = element.get_bounding_client_rect();
let computed_style = web_sys::window()
.unwrap()
.get_computed_style(element)?
.unwrap();
let transform = computed_style
.get_property_value("transform")
.unwrap_or_else(|_| "none".to_string());
let opacity = computed_style
.get_property_value("opacity")
.unwrap_or_else(|_| "1".to_string())
.parse::<f64>()
.unwrap_or(1.0);
let scale = Self::parse_scale_from_transform(&transform);
let rotation = Self::parse_rotation_from_transform(&transform);
Ok(Self {
rect,
transform,
opacity,
scale,
rotation,
})
}
fn parse_scale_from_transform(transform: &str) -> (f64, f64) {
if transform == "none" {
return (1.0, 1.0);
}
if transform.starts_with("matrix(") {
let values: Vec<f64> = transform
.trim_start_matches("matrix(")
.trim_end_matches(")")
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
if values.len() >= 4 {
return (values[0], values[3]);
}
}
(1.0, 1.0)
}
fn parse_rotation_from_transform(transform: &str) -> f64 {
if transform == "none" {
return 0.0;
}
if transform.starts_with("rotate(") {
let value = transform
.trim_start_matches("rotate(")
.trim_end_matches("deg)")
.trim_end_matches(")")
.parse::<f64>()
.unwrap_or(0.0);
return value;
}
0.0
}
pub fn calculate_transform_to(&self, target: &LayoutInfo) -> HashMap<String, AnimationValue> {
let mut transform = HashMap::new();
let delta_x = target.rect.left() - self.rect.left();
let delta_y = target.rect.top() - self.rect.top();
transform.insert("x".to_string(), AnimationValue::Pixels(delta_x));
transform.insert("y".to_string(), AnimationValue::Pixels(delta_y));
let scale_x = target.scale.0 / self.scale.0;
let scale_y = target.scale.1 / self.scale.1;
if scale_x != 1.0 || scale_y != 1.0 {
transform.insert("scaleX".to_string(), AnimationValue::Number(scale_x));
transform.insert("scaleY".to_string(), AnimationValue::Number(scale_y));
}
let rotation_delta = target.rotation - self.rotation;
if rotation_delta != 0.0 {
transform.insert(
"rotateZ".to_string(),
AnimationValue::Number(rotation_delta),
);
}
let opacity_delta = target.opacity - self.opacity;
if opacity_delta != 0.0 {
transform.insert(
"opacity".to_string(),
AnimationValue::Number(target.opacity),
);
}
transform
}
}
pub struct FLIPManager {
config: FLIPConfig,
state: FLIPState,
first_layout: Option<LayoutInfo>,
last_layout: Option<LayoutInfo>,
current_element: Option<Element>,
animation_id: Option<i32>,
}
impl FLIPManager {
pub fn new(config: FLIPConfig) -> Self {
Self {
config,
state: FLIPState::First,
first_layout: None,
last_layout: None,
current_element: None,
animation_id: None,
}
}
pub fn record_first(&mut self, element: &WebElement) -> std::result::Result<(), JsValue> {
self.first_layout = Some(LayoutInfo::from_element(element)?);
self.current_element = Some(element.clone());
self.state = FLIPState::First;
Ok(())
}
pub fn record_last_and_play(
&mut self,
element: &WebElement,
) -> std::result::Result<(), JsValue> {
self.last_layout = Some(LayoutInfo::from_element(element)?);
self.state = FLIPState::Last;
self.play_animation()?;
Ok(())
}
fn play_animation(&mut self) -> std::result::Result<(), JsValue> {
let first_layout = self
.first_layout
.as_ref()
.ok_or_else(|| JsValue::from_str("No first layout recorded"))?;
let last_layout = self
.last_layout
.as_ref()
.ok_or_else(|| JsValue::from_str("No last layout recorded"))?;
let element = self
.current_element
.as_ref()
.ok_or_else(|| JsValue::from_str("No element set"))?;
let transform = first_layout.calculate_transform_to(last_layout);
self.apply_layout(element, last_layout)?;
self.invert_element(element, first_layout, last_layout)?;
self.state = FLIPState::Inverted;
let element_clone = element.clone();
self.animate_to_final(&element_clone, &transform)?;
Ok(())
}
fn apply_layout(
&self,
element: &Element,
layout: &LayoutInfo,
) -> std::result::Result<(), JsValue> {
let style: CssStyleDeclaration = element.style();
style.set_property("left", &format!("{}px", layout.rect.left()))?;
style.set_property("top", &format!("{}px", layout.rect.top()))?;
style.set_property("width", &format!("{}px", layout.rect.width()))?;
style.set_property("height", &format!("{}px", layout.rect.height()))?;
style.set_property("transform", &layout.transform)?;
style.set_property("opacity", &layout.opacity.to_string())?;
style.set_property("z-index", &self.config.z_index.to_string())?;
Ok(())
}
fn invert_element(
&self,
element: &Element,
first: &LayoutInfo,
last: &LayoutInfo,
) -> std::result::Result<(), JsValue> {
let style: CssStyleDeclaration = element.style();
let delta_x = first.rect.left() - last.rect.left();
let delta_y = first.rect.top() - last.rect.top();
let scale_x = first.scale.0 / last.scale.0;
let scale_y = first.scale.1 / last.scale.1;
let rotation_delta = first.rotation - last.rotation;
let inverse_transform = format!(
"translate({}px, {}px) scale({}, {}) rotate({}deg)",
delta_x, delta_y, scale_x, scale_y, rotation_delta
);
style.set_property("transform", &inverse_transform)?;
if self.config.animate_opacity {
style.set_property("opacity", &first.opacity.to_string())?;
}
Ok(())
}
fn animate_to_final(
&mut self,
element: &Element,
_transform: &HashMap<String, AnimationValue>,
) -> std::result::Result<(), JsValue> {
self.state = FLIPState::Playing;
let mut final_transform = HashMap::new();
final_transform.insert("x".to_string(), AnimationValue::Pixels(0.0));
final_transform.insert("y".to_string(), AnimationValue::Pixels(0.0));
final_transform.insert("scaleX".to_string(), AnimationValue::Number(1.0));
final_transform.insert("scaleY".to_string(), AnimationValue::Number(1.0));
final_transform.insert("rotateZ".to_string(), AnimationValue::Number(0.0));
if self.config.animate_opacity {
final_transform.insert("opacity".to_string(), AnimationValue::Number(1.0));
}
self.start_animation(element, &final_transform)?;
Ok(())
}
fn start_animation(
&mut self,
element: &Element,
target: &HashMap<String, AnimationValue>,
) -> std::result::Result<(), JsValue> {
let style: CssStyleDeclaration = element.style();
style.set_property(
"transition",
&format!(
"transform {}s {}, opacity {}s {}",
self.config.duration, "ease-out", self.config.duration, "ease-out"
),
)?;
let final_transform = self.build_transform_string(target);
style.set_property("transform", &final_transform)?;
if self.config.animate_opacity {
if let Some(opacity) = target.get("opacity") {
if let AnimationValue::Number(opacity_val) = opacity {
style.set_property("opacity", &opacity_val.to_string())?;
}
}
}
let element_clone = element.clone();
let callback = Closure::wrap(Box::new(move || {
console_log!("FLIP animation completed");
}) as Box<dyn FnMut()>);
element
.add_event_listener_with_callback("transitionend", callback.as_ref().unchecked_ref())?;
callback.forget();
Ok(())
}
fn build_transform_string(&self, values: &HashMap<String, AnimationValue>) -> String {
let mut transforms = Vec::new();
if let Some(x) = values.get("x") {
if let AnimationValue::Pixels(x_val) = x {
if let Some(y) = values.get("y") {
if let AnimationValue::Pixels(y_val) = y {
transforms.push(format!("translate({}px, {}px)", x_val, y_val));
}
}
}
}
if let Some(scale_x) = values.get("scaleX") {
if let AnimationValue::Number(scale_x_val) = scale_x {
if let Some(scale_y) = values.get("scaleY") {
if let AnimationValue::Number(scale_y_val) = scale_y {
transforms.push(format!("scale({}, {})", scale_x_val, scale_y_val));
}
}
}
}
if let Some(rotate) = values.get("rotateZ") {
if let AnimationValue::Number(rotate_val) = rotate {
transforms.push(format!("rotate({}deg)", rotate_val));
}
}
if transforms.is_empty() {
"none".to_string()
} else {
transforms.join(" ")
}
}
pub fn get_state(&self) -> &FLIPState {
&self.state
}
pub fn is_animating(&self) -> bool {
matches!(self.state, FLIPState::Playing)
}
pub fn reset(&mut self) {
self.state = FLIPState::First;
self.first_layout = None;
self.last_layout = None;
self.current_element = None;
self.animation_id = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_flip_config_default() {
let config = FLIPConfig::default();
assert_eq!(config.duration, 0.3);
assert_eq!(config.ease, Easing::EaseOut);
assert!(config.animate_scale);
assert!(!config.animate_rotation);
assert!(!config.animate_opacity);
assert_eq!(config.z_index, 1000);
}
#[wasm_bindgen_test]
fn test_flip_config_custom() {
let config = FLIPConfig {
duration: 0.5,
ease: Easing::EaseInOut,
animate_scale: false,
animate_rotation: true,
animate_opacity: true,
z_index: 2000,
};
assert_eq!(config.duration, 0.5);
assert_eq!(config.ease, Easing::EaseInOut);
assert!(!config.animate_scale);
assert!(config.animate_rotation);
assert!(config.animate_opacity);
assert_eq!(config.z_index, 2000);
}
#[wasm_bindgen_test]
fn test_flip_manager_creation() {
let config = FLIPConfig::default();
let manager = FLIPManager::new(config);
assert_eq!(manager.get_state(), &FLIPState::First);
assert!(!manager.is_animating());
}
#[wasm_bindgen_test]
fn test_flip_manager_reset() {
let config = FLIPConfig::default();
let mut manager = FLIPManager::new(config);
manager.reset();
assert_eq!(manager.get_state(), &FLIPState::First);
assert!(!manager.is_animating());
}
#[wasm_bindgen_test]
fn test_layout_info_scale_parsing() {
let scale = LayoutInfo::parse_scale_from_transform("matrix(2, 0, 0, 3, 0, 0)");
assert_eq!(scale, (2.0, 3.0));
let scale_none = LayoutInfo::parse_scale_from_transform("none");
assert_eq!(scale_none, (1.0, 1.0));
}
#[wasm_bindgen_test]
fn test_layout_info_rotation_parsing() {
let rotation = LayoutInfo::parse_rotation_from_transform("rotate(45deg)");
assert_eq!(rotation, 45.0);
let rotation_none = LayoutInfo::parse_rotation_from_transform("none");
assert_eq!(rotation_none, 0.0);
}
#[wasm_bindgen_test]
fn test_layout_info_transform_calculation() {
let first_rect =
DomRect::new_with_x_and_y_and_width_and_height(0.0, 0.0, 100.0, 100.0).unwrap();
let last_rect =
DomRect::new_with_x_and_y_and_width_and_height(50.0, 50.0, 100.0, 100.0).unwrap();
let first_layout = LayoutInfo {
rect: first_rect,
transform: "none".to_string(),
opacity: 1.0,
scale: (1.0, 1.0),
rotation: 0.0,
};
let last_layout = LayoutInfo {
rect: last_rect,
transform: "none".to_string(),
opacity: 0.8,
scale: (1.5, 1.5),
rotation: 45.0,
};
let transform = first_layout.calculate_transform_to(&last_layout);
assert_eq!(transform.get("x"), Some(&AnimationValue::Pixels(50.0)));
assert_eq!(transform.get("y"), Some(&AnimationValue::Pixels(50.0)));
assert_eq!(transform.get("scaleX"), Some(&AnimationValue::Number(1.5)));
assert_eq!(transform.get("scaleY"), Some(&AnimationValue::Number(1.5)));
assert_eq!(
transform.get("rotateZ"),
Some(&AnimationValue::Number(45.0))
);
assert_eq!(transform.get("opacity"), Some(&AnimationValue::Number(0.8)));
}
#[wasm_bindgen_test]
fn test_flip_states() {
assert_eq!(FLIPState::First, FLIPState::First);
assert_eq!(FLIPState::Last, FLIPState::Last);
assert_eq!(FLIPState::Inverted, FLIPState::Inverted);
assert_eq!(FLIPState::Playing, FLIPState::Playing);
assert_eq!(FLIPState::Completed, FLIPState::Completed);
}
#[wasm_bindgen_test]
fn test_flip_manager_state_transitions() {
let config = FLIPConfig::default();
let manager = FLIPManager::new(config);
assert_eq!(manager.get_state(), &FLIPState::First);
assert!(!manager.is_animating());
}
}