Implement renderer-backed Web Animations
This commit is contained in:
1 parent
0a8c0feb06
commit
2b92236a2f
6 files changed
+848
-12
No files matched your search
@@ -17,6 +17,9 @@ use obscura_net::{
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::import_map::ImportMap;
|
||||
|
||||
pub type InterceptCallback = Arc<
|
||||
@@ -3252,6 +3255,8 @@ pub fn build_extension() -> Extension {
|
||||
ops.push(op_element_scroll_to());
|
||||
ops.push(op_scroll_offset());
|
||||
ops.push(op_scroll_to());
|
||||
ops.push(op_waapi_create());
|
||||
ops.push(op_waapi_control());
|
||||
}
|
||||
Extension {
|
||||
name: "obscura_dom",
|
||||
@@ -3260,6 +3265,143 @@ pub fn build_extension() -> Extension {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WaapiCreateInput {
|
||||
id: u64,
|
||||
node: u32,
|
||||
keyframes: Vec<WaapiKeyframeInput>,
|
||||
duration: f32,
|
||||
delay: f32,
|
||||
iterations: f32,
|
||||
fill: String,
|
||||
direction: String,
|
||||
easing_bezier: Option<[f32; 4]>,
|
||||
linear_easing: Option<Vec<f32>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(Deserialize)]
|
||||
struct WaapiKeyframeInput {
|
||||
offset: f32,
|
||||
opacity: Option<f32>,
|
||||
transform: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn waapi_document_time_ms(state: &ObscuraState) -> f32 {
|
||||
state.animation_timeline_origin.elapsed().as_secs_f32() * 1000.0
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn invalidate_waapi_render(state: &mut ObscuraState) {
|
||||
state.prepared_render = None;
|
||||
state.resolved_scroll = None;
|
||||
state.activity_generation = state.activity_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(fast)]
|
||||
fn op_waapi_create(state: &OpState, #[string] input: &str) -> bool {
|
||||
let Ok(input) = serde_json::from_str::<WaapiCreateInput>(input) else {
|
||||
return false;
|
||||
};
|
||||
if !input.duration.is_finite()
|
||||
|| input.duration < 0.0
|
||||
|| !input.delay.is_finite()
|
||||
|| !input.iterations.is_finite()
|
||||
|| input.iterations < 0.0
|
||||
|| input.keyframes.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut state = shared.borrow_mut();
|
||||
let node = NodeId::new(input.node);
|
||||
if state.dom.as_ref().and_then(|dom| dom.get_node(node)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let start_time_ms = waapi_document_time_ms(&state);
|
||||
let fill_mode = match input.fill.as_str() {
|
||||
"forwards" => obscura_render::AnimationFillMode::Forwards,
|
||||
"backwards" => obscura_render::AnimationFillMode::Backwards,
|
||||
"both" => obscura_render::AnimationFillMode::Both,
|
||||
_ => obscura_render::AnimationFillMode::None,
|
||||
};
|
||||
let direction = match input.direction.as_str() {
|
||||
"reverse" => obscura_render::AnimationDirection::Reverse,
|
||||
"alternate" => obscura_render::AnimationDirection::Alternate,
|
||||
"alternate-reverse" => obscura_render::AnimationDirection::AlternateReverse,
|
||||
_ => obscura_render::AnimationDirection::Normal,
|
||||
};
|
||||
state.animation_timeline.register_waapi(obscura_render::WaapiAnimation {
|
||||
id: input.id,
|
||||
node,
|
||||
keyframes: input.keyframes.into_iter().map(|frame| obscura_render::WaapiKeyframe {
|
||||
offset: frame.offset.clamp(0.0, 1.0),
|
||||
opacity: frame.opacity.map(|value| value.clamp(0.0, 1.0)),
|
||||
transform: frame.transform,
|
||||
}).collect(),
|
||||
timing: obscura_render::AnimationTiming {
|
||||
duration_ms: input.duration,
|
||||
delay_ms: input.delay,
|
||||
iteration_count: input.iterations,
|
||||
direction,
|
||||
fill_mode,
|
||||
play_state: obscura_render::AnimationPlayState::Running,
|
||||
},
|
||||
easing: input.easing_bezier,
|
||||
linear_easing: input.linear_easing,
|
||||
start_time_ms,
|
||||
hold_time_ms: None,
|
||||
play_state: obscura_render::WaapiPlayState::Running,
|
||||
});
|
||||
invalidate_waapi_render(&mut state);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(fast)]
|
||||
fn op_waapi_control(
|
||||
state: &OpState,
|
||||
id: f64,
|
||||
#[string] action: &str,
|
||||
value: f64,
|
||||
) -> bool {
|
||||
if !id.is_finite() || id < 0.0 {
|
||||
return false;
|
||||
}
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut state = shared.borrow_mut();
|
||||
let id = id as u64;
|
||||
let document_time = waapi_document_time_ms(&state);
|
||||
let changed = match action {
|
||||
"cancel" => state.animation_timeline.cancel_waapi(id),
|
||||
"finish" => state.animation_timeline.finish_waapi(id),
|
||||
"pause" => state.animation_timeline.set_waapi_play_state(
|
||||
id,
|
||||
obscura_render::WaapiPlayState::Paused,
|
||||
document_time,
|
||||
),
|
||||
"play" => state.animation_timeline.set_waapi_play_state(
|
||||
id,
|
||||
obscura_render::WaapiPlayState::Running,
|
||||
document_time,
|
||||
),
|
||||
"currentTime" if value.is_finite() => state.animation_timeline.set_waapi_current_time(
|
||||
id,
|
||||
document_time,
|
||||
value as f32,
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if changed {
|
||||
invalidate_waapi_render(&mut state);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn document_base_url(state: &ObscuraState) -> Option<String> {
|
||||
let document_url = url::Url::parse(&state.url).ok()?;
|
||||
|
||||
@@ -6723,6 +6723,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn waapi_pause_seek_and_cancel_preserve_authored_inline_style() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><body><div id="box" style="opacity:.2;width:20px;height:20px"></div></body></html>"#,
|
||||
);
|
||||
rt.execute_script(
|
||||
"waapi",
|
||||
r#"
|
||||
globalThis.box = document.getElementById('box');
|
||||
globalThis.__animation = box.animate(
|
||||
[{opacity:.2, transform:'translateX(0px)'}, {opacity:1, transform:'translateX(100px)'}],
|
||||
{duration:100, fill:'both', easing:'linear'}
|
||||
);
|
||||
__animation.pause();
|
||||
__animation.currentTime = 50;
|
||||
"#,
|
||||
).unwrap();
|
||||
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
|
||||
assert_eq!(rt.evaluate("box.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
|
||||
assert_eq!(rt.evaluate("document.getAnimations()[0] === __animation").unwrap(), serde_json::json!(true));
|
||||
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("paused"));
|
||||
assert_eq!(
|
||||
rt.evaluate("!('easingBezier' in __animation.effect.getTiming()) && !('linearEasing' in __animation.effect.getComputedTiming())").unwrap(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
let opacity = rt.evaluate("getComputedStyle(box).opacity").unwrap();
|
||||
let opacity = opacity.as_str().unwrap().parse::<f32>().unwrap();
|
||||
assert!((opacity - 0.6).abs() < 0.001, "midpoint opacity was {opacity}");
|
||||
|
||||
rt.execute_script("cancel", "__animation.cancel()").unwrap();
|
||||
assert_eq!(rt.evaluate("box.style.opacity").unwrap(), serde_json::json!(".2"));
|
||||
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("0.2"));
|
||||
assert_eq!(rt.evaluate("box.getAnimations().length").unwrap(), serde_json::json!(0.0));
|
||||
assert_eq!(rt.evaluate("document.getAnimations().length").unwrap(), serde_json::json!(0.0));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn waapi_zero_duration_finishes_asynchronously_and_fires_lifecycle() {
|
||||
let mut rt = setup_runtime(r#"<div id="box" style="opacity:.1"></div>"#);
|
||||
rt.execute_script(
|
||||
"waapi-lifecycle",
|
||||
r#"
|
||||
globalThis.box = document.getElementById('box');
|
||||
globalThis.__ready = false;
|
||||
globalThis.__finished = false;
|
||||
globalThis.__finishEvent = false;
|
||||
globalThis.__animation = box.animate([{opacity:.1}, {opacity:1}], {duration:0, fill:'both'});
|
||||
__animation.onfinish = () => { __finishEvent = true; };
|
||||
__animation.ready.then(() => { __ready = true; });
|
||||
__animation.finished.then(() => { __finished = true; });
|
||||
"#,
|
||||
).unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
assert_eq!(rt.evaluate("__ready").unwrap(), serde_json::json!(true));
|
||||
assert_eq!(rt.evaluate("__finished").unwrap(), serde_json::json!(true));
|
||||
assert_eq!(rt.evaluate("__finishEvent").unwrap(), serde_json::json!(true));
|
||||
assert_eq!(rt.evaluate("__animation.playState").unwrap(), serde_json::json!("finished"));
|
||||
assert_eq!(rt.evaluate("getComputedStyle(box).opacity").unwrap(), serde_json::json!("1"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn forward_animation_samples_retain_static_prepared_render() {
|
||||
|
||||
Reference in new issue
Block a user