script/dom/animations/keyframeeffect.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::ffi::CString;
6use std::ops::ControlFlow;
7use std::ptr::{self, NonNull};
8use std::sync::LazyLock;
9
10use cssparser::Parser;
11use dom_struct::dom_struct;
12use js::context::JSContext;
13use js::conversions::{
14 ConversionResult, FromJSValConvertible, ToJSValConvertible, jsstr_to_string,
15};
16use js::gc::{HandleValue, RootedVec};
17use js::jsapi::{HandleId, Heap, JS_GetPropertyById, JSITER_OWNONLY, JSObject};
18use js::jsval::{ObjectValue, UndefinedValue};
19use js::rust::wrappers2::{GetPropertyKeys, JS_IdToValue, JS_NewObject};
20use js::rust::{
21 ForOfIterationFailure, HandleObject, IdVector, IntoHandle, IntoMutableHandle, for_of,
22};
23use rustc_hash::FxHashMap;
24use script_bindings::cell::DomRefCell;
25use script_bindings::codegen::GenericBindings::KeyframeEffectBinding::{
26 BaseKeyframe, CompositeOperationOrAuto,
27};
28use script_bindings::codegen::GenericBindings::WindowBinding::WindowMethods;
29use script_bindings::codegen::GenericUnionTypes::UnrestrictedDoubleOrKeyframeEffectOptions;
30use script_bindings::conversions::StringificationBehavior;
31use script_bindings::error::{Error, Fallible};
32use script_bindings::inheritance::Castable;
33use script_bindings::js::{JS_DefineProperty, PropertyFlags};
34use script_bindings::num::Finite;
35use script_bindings::reflector::reflect_dom_object_with_proto;
36use script_bindings::root::DomRoot;
37use script_bindings::str::DOMString;
38use style::parser::ParserContext;
39use style::properties::generated::PropertyDeclaration;
40use style::properties::{
41 Importance, LonghandId, NonCustomPropertyId, PropertyDeclarationBlock, PropertyId,
42 SourcePropertyDeclaration,
43};
44use style::stylesheets::CssRuleType;
45use style_traits::{CssWriter, ParsingMode, ToCss};
46
47use crate::css::css::parser_context_for_document;
48use crate::dom::Document;
49use crate::dom::animationeffect::AnimationEffect;
50use crate::dom::bindings::codegen::Bindings::KeyframeEffectBinding::{
51 BaseComputedKeyframe, KeyframeEffectMethods,
52};
53use crate::dom::bindings::root::MutNullableDom;
54use crate::dom::element::Element;
55use crate::dom::window::Window;
56
57/// <https://drafts.csswg.org/web-animations-1/#keyframeeffect>
58#[dom_struct]
59pub(crate) struct KeyframeEffect {
60 animationeffect: AnimationEffect,
61
62 /// <https://drafts.csswg.org/web-animations-1/#effect-target-target-element>
63 // FIXME: Store a target pseudo-selector
64 // to fully match the concept of the effect target
65 //
66 // https://drafts.csswg.org/web-animations-1/#effect-target-target-pseudo-selector
67 // https://drafts.csswg.org/web-animations-1/#keyframe-effect-effect-target.
68 target_element: MutNullableDom<Element>,
69
70 /// <https://drafts.csswg.org/web-animations-1/#keyframe>
71 keyframes: DomRefCell<Vec<Keyframe>>,
72}
73
74impl KeyframeEffect {
75 pub(crate) fn new_inherited(window: &Window) -> Self {
76 Self {
77 animationeffect: AnimationEffect::new_inherited(window),
78 target_element: Default::default(),
79 keyframes: Default::default(),
80 }
81 }
82
83 fn new_with_proto(
84 cx: &mut JSContext,
85 window: &Window,
86 proto: Option<HandleObject>,
87 ) -> DomRoot<Self> {
88 reflect_dom_object_with_proto(cx, Box::new(Self::new_inherited(window)), window, proto)
89 }
90
91 pub(crate) fn new(cx: &mut JSContext, window: &Window) -> DomRoot<Self> {
92 Self::new_with_proto(cx, window, None)
93 }
94}
95
96impl KeyframeEffectMethods<crate::DomTypeHolder> for KeyframeEffect {
97 /// <https://drafts.csswg.org/web-animations-1/#dom-keyframeeffect-keyframeeffect>
98 fn Constructor(
99 cx: &mut JSContext,
100 window: &Window,
101 _: Option<HandleObject>,
102 target: Option<&Element>,
103 keyframes: *mut JSObject,
104 _options: UnrestrictedDoubleOrKeyframeEffectOptions,
105 ) -> DomRoot<KeyframeEffect> {
106 // Step 1. Create a new KeyframeEffect object, effect.
107 let effect = KeyframeEffect::new(cx, window);
108
109 // Step 2. Set the target element of effect to target.
110 effect.target_element.set(target);
111
112 // TODO: Step 3. Set the target pseudo-selector to the result corresponding to
113 // the first matching condition below:
114
115 // TODO: Step 4. Let timing input be the result corresponding to the first matching
116 // condition below:
117
118 // Step 5. Call the procedure to update the timing properties of an animation effect of
119 // effect from timing input.
120 // If that procedure causes an exception to be thrown, propagate the exception and abort this procedure.
121
122 // TODO: Step 6. If options is a KeyframeEffectOptions object, assign the composite property of effect
123 // to the corresponding value from options.
124 //
125 // When assigning this property, the error-handling defined for the corresponding setter on the
126 // KeyframeEffect interface is applied. If the setter requires an exception to be thrown for the value
127 // specified by options, this procedure must throw the same exception and abort all further steps.
128
129 // Step 7. Initialize the set of keyframes by performing the procedure defined for setKeyframes()
130 // passing keyframes as the input.
131 effect.SetKeyframes(cx, keyframes);
132
133 effect
134 }
135
136 /// <https://drafts.csswg.org/web-animations-1/#dom-keyframeeffect-getkeyframes>
137 #[expect(unsafe_code)]
138 fn GetKeyframes(
139 &self,
140 cx: &mut JSContext,
141 result: &mut RootedVec<'_, Box<Heap<*mut JSObject>>>,
142 ) -> Fallible<()> {
143 let window = self.upcast::<AnimationEffect>().window();
144
145 // Step 1. Let result be an empty sequence of objects.
146 debug_assert!(result.is_empty());
147
148 // Step 2. Let keyframes be one of the following:
149 // If this keyframe effect is associated with a CSSAnimation, and its keyframes have not been replaced
150 // by a successful call to setKeyframes(),
151 // the computed keyframes for this keyframe effect.
152 // Otherwise,
153 // the result of applying the procedure compute missing keyframe offsets to the keyframes for this keyframe effect.
154 // TODO: We don't compute missing keyframe offsets yet. But that will likely happen in stylo, not here.
155 let keyframes = self.keyframes.borrow();
156
157 // Step 3. For each keyframe in keyframes perform the following steps:
158 for keyframe in keyframes.iter() {
159 // Step 3.1 Initialize a dictionary object, output keyframe, using the following definition:
160 // TODO Step 3.2 Set the offset, computedOffset, easing, and composite members of output keyframe
161 // to the respective keyframe offset, computed keyframe offset, keyframe-specific easing function,
162 // and keyframe-specific composite operation values of keyframe.
163 let base_keyframe = BaseComputedKeyframe {
164 composite: keyframe.composite,
165 offset: keyframe.offset,
166 // FIXME: We don't post-process the offset of keyframes to find suitable offset values for null
167 // keyframe offsets yet, so we just use the offset as-is.
168 computedOffset: keyframe.offset,
169 easing: keyframe.easing_function.clone(),
170 };
171 rooted!(&in(cx) let mut output_keyframe = unsafe { JS_NewObject(cx, ptr::null()) });
172 base_keyframe.to_jsobject(cx, output_keyframe.handle_mut());
173
174 // Step 3.3 For each animation property-value pair declaration in keyframe, perform the following steps:
175 for property_value_pair in &keyframe.declarations {
176 debug_assert!(property_value_pair.property_id.is_animatable());
177
178 // Step 3.3.1 Let property name be the result of applying the animation property name to IDL attribute
179 // name algorithm to the property name of declaration.
180 let mut property_name = String::new();
181 let mut writer = CssWriter::new(&mut property_name);
182 if property_value_pair.property_id.to_css(&mut writer).is_err() {
183 continue;
184 }
185 let property_name = animation_property_name_to_idl_attribute_name(&property_name);
186
187 // Step 3.3.2 Let IDL value be the result of serializing the property value of declaration
188 // by passing declaration to the algorithm to serialize a CSS value [CSSOM].
189 let mut value_string = String::new();
190 let is_single_value_value_to_css = {
191 let mut layout = window.layout_mut();
192 property_value_pair.block.single_value_to_css(
193 &property_value_pair.property_id,
194 &mut value_string,
195 None,
196 layout.stylist_mut(),
197 )
198 };
199 if is_single_value_value_to_css.is_err() {
200 continue;
201 }
202
203 // Step 3.3.3 Let value be the result of converting IDL value to an ECMAScript String value.
204 rooted!(&in(cx) let mut value = UndefinedValue());
205 value_string.to_jsval(cx, value.handle_mut());
206
207 // Step 3.3.4 Call the [[DefineOwnProperty]] internal method on output keyframe with property
208 // name property name, Property Descriptor { [[Writable]]: true, [[Enumerable]]: true, [[Configurable]]:
209 // true, [[Value]]: value } and Boolean flag false.
210 let Ok(property_name) = CString::new(property_name) else {
211 continue;
212 };
213
214 let success = JS_DefineProperty(
215 cx,
216 output_keyframe.handle(),
217 &property_name,
218 value.handle(),
219 PropertyFlags::ENUMERABLE |
220 PropertyFlags::WRITABLE |
221 PropertyFlags::CONFIGURABLE,
222 );
223 if !success {
224 if cfg!(debug_assertions) {
225 unreachable!("Setting a property on output_keyframe should never fail");
226 }
227 return Err(Error::Operation(None));
228 }
229 }
230
231 // Step 3.4 Append output keyframe to result.
232 result.push(Heap::boxed(output_keyframe.get()))
233 }
234
235 // Step 4. Return result.
236 Ok(())
237 }
238
239 /// <https://drafts.csswg.org/web-animations-1/#dom-keyframeeffect-setkeyframes>
240 fn SetKeyframes(&self, cx: &mut JSContext, keyframes: *mut JSObject) {
241 // > This effect’s set of keyframes is replaced with the result of performing the procedure to
242 // > process a keyframes argument. If that procedure throws an exception, this effect’s
243 // > keyframes are not modified.
244 let document = self.upcast::<AnimationEffect>().window().Document();
245 let Ok(keyframes) = process_a_keyframes_argument(cx, &document, keyframes) else {
246 return;
247 };
248 *self.keyframes.safe_borrow_mut(cx.no_gc()) = keyframes;
249 }
250}
251
252/// <https://drafts.csswg.org/web-animations-1/#process-a-keyframes-argument>
253fn process_a_keyframes_argument(
254 cx: &mut JSContext,
255 document: &Document,
256 keyframes: *mut JSObject,
257) -> Fallible<Vec<Keyframe>> {
258 // Step 1. If object is null, return an empty sequence of keyframes.
259 if keyframes.is_null() {
260 return Ok(Vec::new());
261 }
262
263 // Step 2. Let processed keyframes be an empty sequence of keyframes.
264
265 // Step 3. Let method be the result of GetMethod(object, @@iterator).
266 // Step 4. Check the completion record of method.
267 // Step 5. Perform the steps corresponding to the first matching condition below:
268 rooted!(&in(cx) let iterable = ObjectValue(keyframes));
269 let mut keyframes = Vec::new();
270 let result = for_of(cx, iterable.handle(), |cx, iterator_element| {
271 // Step 5.3.4 Let nextItem be IteratorValue(next).
272 // Step 5.3.5 Check the completion record of nextItem.
273 // Note: This happens inside the "for_of" call.
274
275 // Step 5.3.6 If Type(nextItem) is not Undefined, Null or Object, then throw a TypeError
276 // and abort these steps.
277 if !iterator_element.is_null_or_undefined() && !iterator_element.is_object() {
278 return Err(ForOfIterationFailure::Other(Error::Type(
279 c"Keyframe must be an object, null or undefined".to_owned(),
280 )));
281 }
282
283 // Step 5.3.7 Append to processed keyframes the result of running the procedure to process a
284 // keyframe-like object passing nextItem as the keyframe input with the allow lists flag set to false.
285 keyframes.push(process_a_keyframe_like_object(
286 cx,
287 document,
288 iterator_element,
289 )?);
290
291 Ok(ControlFlow::Continue(()))
292 });
293 match result {
294 Ok(()) => Ok(keyframes),
295 Err(ForOfIterationFailure::ValueIsNotIterable) => {
296 // TODO: Step 5, Otherwise:
297 Err(Error::Operation(None))
298 },
299 Err(ForOfIterationFailure::JSFailed) => Err(Error::JSFailed),
300 Err(ForOfIterationFailure::Other(error)) => Err(error),
301 }
302}
303
304/// <https://drafts.csswg.org/web-animations-1/#keyframe>
305#[derive(JSTraceable, MallocSizeOf)]
306struct Keyframe {
307 offset: Option<Finite<f64>>,
308 easing_function: DOMString,
309 composite: CompositeOperationOrAuto,
310 declarations: Vec<KeyframePropertyDeclaration>,
311}
312
313#[derive(JSTraceable, MallocSizeOf)]
314struct KeyframePropertyDeclaration {
315 #[no_trace]
316 property_id: PropertyId,
317 /// The block is known to only contain declarations for `property_id`. There might
318 /// be more than one value if `property_id` is a shorthand.
319 #[no_trace]
320 block: PropertyDeclarationBlock,
321}
322
323/// <https://drafts.csswg.org/web-animations-1/#process-a-keyframe-like-object>
324fn process_a_keyframe_like_object(
325 cx: &mut JSContext,
326 document: &Document,
327 keyframe_input: HandleValue,
328) -> Fallible<Keyframe> {
329 // Step 1. Run the procedure to convert an ECMAScript value to a dictionary type [WEBIDL] with keyframe input
330 // as the ECMAScript value, and the dictionary type depending on the value of the allow lists flag as follows:
331 // If allow lists is true,
332 // Use the following dictionary type:
333 //
334 // dictionary BasePropertyIndexedKeyframe {
335 // (double? or sequence<double?>) offset = [];
336 // (DOMString or sequence<DOMString>) easing = [];
337 // (CompositeOperationOrAuto or sequence<CompositeOperationOrAuto>) composite = [];
338 // };
339 //
340 // Otherwise,
341 // Use the following dictionary type:
342 //
343 // dictionary BaseKeyframe {
344 // double? offset = null;
345 // DOMString easing = "linear";
346 // CompositeOperationOrAuto composite = "auto";
347 // };
348 //
349 // Store the result of this procedure as keyframe output.
350 //
351 // Note: 'allow lists' is currently never true.
352 // Use the following dictionary type:
353 let Ok(keyframe_output) = BaseKeyframe::from_jsval(cx, keyframe_input, ()) else {
354 return Err(Error::JSFailed);
355 };
356 let ConversionResult::Success(keyframe_output) = keyframe_output else {
357 return Err(Error::Operation(None));
358 };
359 let mut keyframe_output = Keyframe {
360 offset: keyframe_output.offset,
361 easing_function: keyframe_output.easing,
362 composite: keyframe_output.composite,
363 declarations: Vec::new(),
364 };
365
366 // Step 2. If keyframe input is null or undefined, return keyframe output.
367 if keyframe_input.is_null_or_undefined() {
368 return Ok(keyframe_output);
369 }
370
371 // From Step 3 onwards our implementation diverges from the specification. The spec
372 // wants us to build a list of animatable CSS properties and a list of properties on
373 // the object, then compute the union between the two.
374 //
375 // Instead, we iterate over all properties on the object and then check if they correspond
376 // to an animatable property.
377 let urlextradata = document.url().into_url().into();
378 let mut parser_context = parser_context_for_document(
379 document,
380 CssRuleType::Style,
381 ParsingMode::DEFAULT,
382 &urlextradata,
383 );
384 rooted!(&in(cx) let object = keyframe_input.to_object());
385
386 // Steps 2 - 6 are in get_property_declarations
387 keyframe_output.declarations =
388 get_property_declarations(cx, object.handle(), &mut parser_context)?;
389
390 // Step 7. Return keyframe output.
391 Ok(keyframe_output)
392}
393
394/// Implements Step 2-6 of <https://drafts.csswg.org/web-animations-1/#process-a-keyframe-like-object>.
395#[expect(unsafe_code)]
396fn get_property_declarations(
397 cx: &mut JSContext,
398 object: HandleObject,
399 parser_context: &mut ParserContext<'_>,
400) -> Fallible<Vec<KeyframePropertyDeclaration>> {
401 // The spec tells us to iterate over all animatable properties and see if they're defined
402 // on the object. Instead we can iterate over the own properties of the object and see
403 // if they're animated properties, that's easier.
404 let mut ids = IdVector::new(cx);
405 if !unsafe { GetPropertyKeys(cx, object, JSITER_OWNONLY, ids.handle_mut()) } {
406 return Ok(Vec::new());
407 }
408
409 let mut declarations = Vec::with_capacity(ids.len());
410 for id in ids.iter() {
411 rooted!(&in(cx) let id = *id);
412
413 // See if the id for the current property on the object represents a animatable CSS property.
414 if !id.is_string() {
415 continue;
416 }
417 rooted!(&in(cx) let mut key_value = UndefinedValue());
418 let raw_id: HandleId = id.handle().into();
419 if !unsafe { JS_IdToValue(cx, *raw_id.ptr, key_value.handle_mut()) } {
420 continue;
421 }
422 rooted!(&in(cx) let js_string = key_value.to_string());
423 let Some(js_string) = NonNull::new(js_string.get()) else {
424 continue;
425 };
426 let property_name = unsafe { jsstr_to_string(cx, js_string) };
427
428 let Some(property_id) = lookup_css_property_by_idl_attribute_name(&property_name) else {
429 continue;
430 };
431 debug_assert!(property_id.is_animatable());
432
433 // Step 6.1 Let raw value be the result of calling the [[Get]] internal method on keyframe input,
434 // with property name as the property key and keyframe input as the receiver.
435 // Step 6.2 Check the completion record of raw value.
436 rooted!(&in(cx) let mut property_value = UndefinedValue());
437 if !unsafe {
438 JS_GetPropertyById(
439 cx.raw_cx(),
440 object.into_handle(),
441 id.handle().into_handle(),
442 property_value.handle_mut().into_handle_mut(),
443 )
444 } {
445 continue;
446 }
447
448 // Step 6.3 Convert raw value to a DOMString or to a sequence of DOMStrings property values as follows:
449 // If allow lists is true, [..] (Note: We don't implement "allow lists")
450 // Otherwise,
451 // Let property values be the result of converting raw value to a DOMString using the procedure
452 // for converting an ECMAScript value to a DOMString [WEBIDL].
453 let property_value = match DOMString::from_jsval(
454 cx,
455 property_value.handle(),
456 StringificationBehavior::Default,
457 ) {
458 Ok(ConversionResult::Success(property_value)) => property_value,
459 Ok(ConversionResult::Failure(error_message)) => {
460 return Err(Error::Operation(
461 error_message
462 .to_str()
463 .ok()
464 .map(|message| message.to_owned()),
465 ));
466 },
467 Err(_) => return Err(Error::JSFailed),
468 };
469
470 // Step 6.4 Calculate the normalized property name as the result of applying the IDL attribute name
471 // to animation property name algorithm to property name.
472 // Note: Due to the way our implementation differs from the spec (refer to the comment at
473 // the top of this function), we already have the normalized property name.
474
475 // Parse the property value as a value for the given animatable CSS property.
476 // The specification continues to treat the value as a plain string, but there's not much point.
477 let Some(declaration) =
478 parse_single_property_declaration(property_id, &property_value.str(), parser_context)
479 else {
480 continue;
481 };
482
483 // Step 6.5 Add a property to keyframe output with normalized property name as the property name,
484 // and property values as the property value.
485 declarations.push(declaration);
486 }
487
488 Ok(declarations)
489}
490
491/// Parses `input` as a value for `property`, returning `None` on failure.
492fn parse_single_property_declaration(
493 property: NonCustomPropertyId,
494 input: &str,
495 parser_context: &mut ParserContext<'_>,
496) -> Option<KeyframePropertyDeclaration> {
497 let mut declaration = SourcePropertyDeclaration::default();
498 let mut parser = Parser::new(input);
499
500 // TODO: Consider reporting parse errors somewhere useful, like the devtools console.
501 parser
502 .parse_entirely(|parser| {
503 PropertyDeclaration::parse_into(
504 &mut declaration,
505 PropertyId::NonCustom(property),
506 parser_context,
507 parser,
508 )
509 })
510 .ok()?;
511
512 let mut block = PropertyDeclarationBlock::new();
513 block.extend(declaration.drain(), Importance::Normal);
514
515 Some(KeyframePropertyDeclaration {
516 property_id: PropertyId::NonCustom(property),
517 block,
518 })
519}
520
521/// This is the inverse of <https://drafts.csswg.org/web-animations-1/#animation-property-name-to-idl-attribute-name>.
522fn lookup_css_property_by_idl_attribute_name(attribute_name: &str) -> Option<NonCustomPropertyId> {
523 // TODO: Step 1. If property follows the <custom-property-name> production, return property.
524
525 // Step 2. If property refers to the CSS float property, return the string "cssFloat".
526 if attribute_name == "cssFloat" {
527 return Some(LonghandId::Float.into());
528 }
529
530 // Step 3. If property refers to the CSS offset property, return the string "cssOffset".
531 // NOTE: "offset" is not supported yet.
532
533 // Step 4. Otherwise, return the result of applying the CSS property to IDL attribute algorithm
534 // [CSSOM] to property.
535 static IDL_ATTRIBUTE_TO_ANIMATED_PROPERTY_LOOKUP_TABLE: LazyLock<
536 FxHashMap<String, NonCustomPropertyId>,
537 > = LazyLock::new(|| {
538 log::debug!("Initializing map from IDL attribute names to CSS properties");
539
540 NonCustomPropertyId::iter()
541 .filter(|non_custom_property| non_custom_property.is_animatable())
542 .filter(|non_custom_property| {
543 non_custom_property
544 .to_property_id()
545 .enabled_for_all_content()
546 })
547 .map(|non_custom_property| {
548 let idl_attribute_name =
549 animation_property_name_to_idl_attribute_name(non_custom_property.name());
550 (idl_attribute_name, non_custom_property)
551 })
552 .collect()
553 });
554 IDL_ATTRIBUTE_TO_ANIMATED_PROPERTY_LOOKUP_TABLE
555 .get(attribute_name)
556 .copied()
557}
558
559/// <https://drafts.csswg.org/web-animations-1/#animation-property-name-to-idl-attribute-name>
560///
561/// # Panics
562/// Panics when the property name is empty or consists only of `-` characters.
563/// In general it is assumed that `property_name` is a CSS property.
564fn animation_property_name_to_idl_attribute_name(property_name: &str) -> String {
565 let mut idl_attribute_name = String::with_capacity(property_name.len());
566
567 let mut chunks = property_name.split('-');
568 let Some(first_chunk) = chunks.next() else {
569 unreachable!("CSS property name should not consist only of dashes");
570 };
571 idl_attribute_name.push_str(first_chunk);
572 for chunk in chunks {
573 let mut characters = chunk.chars();
574 let Some(to_capitalize) = characters.next() else {
575 continue;
576 };
577 idl_attribute_name.push(to_capitalize.to_ascii_uppercase());
578 idl_attribute_name.push_str(characters.as_str());
579 }
580
581 idl_attribute_name
582}