Skip to main content

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, ParserInput};
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, JSPROP_ENUMERATE};
18use js::jsval::{ObjectValue, UndefinedValue};
19use js::rust::wrappers2::{GetPropertyKeys, JS_DefineProperty, 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::num::Finite;
34use script_bindings::reflector::reflect_dom_object_with_proto;
35use script_bindings::root::DomRoot;
36use script_bindings::str::DOMString;
37use style::parser::ParserContext;
38use style::properties::generated::PropertyDeclaration;
39use style::properties::{
40    Importance, LonghandId, NonCustomPropertyId, PropertyDeclarationBlock, PropertyId,
41    SourcePropertyDeclaration,
42};
43use style::stylesheets::CssRuleType;
44use style_traits::{CssWriter, ParsingMode, ToCss};
45
46use crate::css::parser_context_for_document;
47use crate::dom::Document;
48use crate::dom::animationeffect::AnimationEffect;
49use crate::dom::bindings::codegen::Bindings::KeyframeEffectBinding::{
50    BaseComputedKeyframe, KeyframeEffectMethods,
51};
52use crate::dom::bindings::root::MutNullableDom;
53use crate::dom::element::Element;
54use crate::dom::window::Window;
55
56/// <https://drafts.csswg.org/web-animations-1/#keyframeeffect>
57#[dom_struct]
58pub(crate) struct KeyframeEffect {
59    animationeffect: AnimationEffect,
60
61    /// <https://drafts.csswg.org/web-animations-1/#effect-target-target-element>
62    // FIXME: Store a target pseudo-selector
63    // to fully match the concept of the effect target
64    //
65    // https://drafts.csswg.org/web-animations-1/#effect-target-target-pseudo-selector
66    // https://drafts.csswg.org/web-animations-1/#keyframe-effect-effect-target.
67    target_element: MutNullableDom<Element>,
68
69    /// <https://drafts.csswg.org/web-animations-1/#keyframe>
70    keyframes: DomRefCell<Vec<Keyframe>>,
71}
72
73impl KeyframeEffect {
74    pub(crate) fn new_inherited(window: &Window) -> Self {
75        Self {
76            animationeffect: AnimationEffect::new_inherited(window),
77            target_element: Default::default(),
78            keyframes: Default::default(),
79        }
80    }
81
82    fn new_with_proto_and_cx(
83        cx: &mut JSContext,
84        window: &Window,
85        proto: Option<HandleObject>,
86    ) -> DomRoot<Self> {
87        reflect_dom_object_with_proto(cx, Box::new(Self::new_inherited(window)), window, proto)
88    }
89
90    pub(crate) fn new(cx: &mut JSContext, window: &Window) -> DomRoot<Self> {
91        Self::new_with_proto_and_cx(cx, window, None)
92    }
93}
94
95impl KeyframeEffectMethods<crate::DomTypeHolder> for KeyframeEffect {
96    /// <https://drafts.csswg.org/web-animations-1/#dom-keyframeeffect-keyframeeffect>
97    fn Constructor(
98        cx: &mut JSContext,
99        window: &Window,
100        _: Option<HandleObject>,
101        target: Option<&Element>,
102        keyframes: *mut JSObject,
103        _options: UnrestrictedDoubleOrKeyframeEffectOptions,
104    ) -> DomRoot<KeyframeEffect> {
105        // Step 1. Create a new KeyframeEffect object, effect.
106        let effect = KeyframeEffect::new(cx, window);
107
108        // Step 2. Set the target element of effect to target.
109        effect.target_element.set(target);
110
111        // TODO: Step 3. Set the target pseudo-selector to the result corresponding to
112        // the first matching condition below:
113
114        // TODO: Step 4. Let timing input be the result corresponding to the first matching
115        // condition below:
116
117        // Step 5. Call the procedure to update the timing properties of an animation effect of
118        // effect from timing input.
119        // If that procedure causes an exception to be thrown, propagate the exception and abort this procedure.
120
121        // TODO: Step 6. If options is a KeyframeEffectOptions object, assign the composite property of effect
122        // to the corresponding value from options.
123        //
124        // When assigning this property, the error-handling defined for the corresponding setter on the
125        //  KeyframeEffect interface is applied. If the setter requires an exception to be thrown for the value
126        //  specified by options, this procedure must throw the same exception and abort all further steps.
127
128        // Step 7. Initialize the set of keyframes by performing the procedure defined for setKeyframes()
129        // passing keyframes as the input.
130        effect.SetKeyframes(cx, keyframes);
131
132        effect
133    }
134
135    /// <https://drafts.csswg.org/web-animations-1/#dom-keyframeeffect-getkeyframes>
136    #[expect(unsafe_code)]
137    fn GetKeyframes(
138        &self,
139        cx: &mut JSContext,
140        result: &mut RootedVec<'_, Box<Heap<*mut JSObject>>>,
141    ) -> Fallible<()> {
142        let mut layout = self.upcast::<AnimationEffect>().window().layout_mut();
143        let stylist = layout.stylist_mut();
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                if property_value_pair
191                    .block
192                    .single_value_to_css(
193                        &property_value_pair.property_id,
194                        &mut value_string,
195                        None,
196                        stylist,
197                    )
198                    .is_err()
199                {
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.safe_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 = unsafe {
215                    JS_DefineProperty(
216                        cx,
217                        output_keyframe.handle(),
218                        property_name.as_ptr(),
219                        value.handle(),
220                        JSPROP_ENUMERATE as u32,
221                    )
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>
253#[expect(unsafe_code)]
254fn process_a_keyframes_argument(
255    cx: &mut JSContext,
256    document: &Document,
257    keyframes: *mut JSObject,
258) -> Fallible<Vec<Keyframe>> {
259    // Step 1. If object is null, return an empty sequence of keyframes.
260    if keyframes.is_null() {
261        return Ok(Vec::new());
262    }
263
264    // Step 2. Let processed keyframes be an empty sequence of keyframes.
265
266    // Step 3. Let method be the result of GetMethod(object, @@iterator).
267    // Step 4. Check the completion record of method.
268    // Step 5. Perform the steps corresponding to the first matching condition below:
269    rooted!(&in(cx) let iterable = ObjectValue(keyframes));
270    let mut keyframes = Vec::new();
271    let result = for_of(
272        unsafe { cx.raw_cx() },
273        iterable.handle(),
274        |iterator_element| {
275            // Step 5.3.6 If Type(nextItem) is not Undefined, Null or Object, then throw a TypeError
276            // and abort these steps.
277            //
278            // Note: nextItem is later passed to "process a keyframe like object" which cannot handle undefined
279            // or null values. This seems to be a bug in the specification which is tracked by
280            // https://github.com/w3c/csswg-drafts/issues/14113
281            if !iterator_element.is_object() {
282                return Err(ForOfIterationFailure::Other(Error::Type(
283                    c"Keyframe must be an object".to_owned(),
284                )));
285            }
286
287            // Step 5.3.7 Append to processed keyframes the result of running the procedure to process a
288            // keyframe-like object passing nextItem as the keyframe input with the allow lists flag set to false.
289            keyframes.push(keyframe_from_value(cx, document, iterator_element)?);
290
291            Ok(ControlFlow::Continue(()))
292        },
293    );
294    match result {
295        Ok(()) => Ok(keyframes),
296        Err(ForOfIterationFailure::ValueIsNotIterable) => {
297            // TODO: Step 5, Otherwise:
298            Err(Error::Operation(None))
299        },
300        Err(ForOfIterationFailure::JSFailed) => Err(Error::JSFailed),
301        Err(ForOfIterationFailure::Other(error)) => Err(error),
302    }
303}
304
305/// <https://drafts.csswg.org/web-animations-1/#keyframe>
306#[derive(JSTraceable, MallocSizeOf)]
307struct Keyframe {
308    offset: Option<Finite<f64>>,
309    easing_function: DOMString,
310    composite: CompositeOperationOrAuto,
311    declarations: Vec<KeyframePropertyDeclaration>,
312}
313
314#[derive(JSTraceable, MallocSizeOf)]
315struct KeyframePropertyDeclaration {
316    #[no_trace]
317    property_id: PropertyId,
318    /// The block is known to only contain declarations for `property_id`. There might
319    /// be more than one value if `property_id` is a shorthand.
320    #[no_trace]
321    block: PropertyDeclarationBlock,
322}
323
324/// Step 5 (for iterable keyframes) of <https://drafts.csswg.org/web-animations-1/#process-a-keyframes-argument>.
325fn keyframe_from_value(
326    cx: &mut JSContext,
327    document: &Document,
328    value: HandleValue<'_>,
329) -> Fallible<Keyframe> {
330    // Step 3.4 Let nextItem be IteratorValue(next).
331    // NOTE: This is "current_value"
332    // Step 3.5 Check the completion record of nextItem.
333
334    // Step 3.6 If Type(nextItem) is not Undefined, Null or Object,
335    // then throw a TypeError and abort these steps.
336    if !value.is_null_or_undefined() && !value.is_object() {
337        return Err(Error::Type(c"Invalid keyframe value".to_owned()));
338    }
339
340    // Step 3.7 Append to processed keyframes the result of running the procedure to process
341    // a keyframe-like object passing nextItem as the keyframe input with the allow lists
342    // flag set to false.
343    process_a_keyframe_like_object(cx, document, value)
344}
345
346/// <https://drafts.csswg.org/web-animations-1/#process-a-keyframe-like-object>
347fn process_a_keyframe_like_object(
348    cx: &mut JSContext,
349    document: &Document,
350    value: HandleValue,
351) -> Fallible<Keyframe> {
352    // Step 1. Run the procedure to convert an ECMAScript value to a dictionary type [WEBIDL] with keyframe input
353    // as the ECMAScript value, and the dictionary type depending on the value of the allow lists flag as follows:
354    // If allow lists is true,
355    // Use the following dictionary type:
356    //
357    // dictionary BasePropertyIndexedKeyframe {
358    //   (double? or sequence<double?>)                         offset = [];
359    //   (DOMString or sequence<DOMString>)                     easing = [];
360    //   (CompositeOperationOrAuto or sequence<CompositeOperationOrAuto>) composite = [];
361    // };
362    //
363    // Otherwise,
364    //     Use the following dictionary type:
365    //
366    //     dictionary BaseKeyframe {
367    //       double?                  offset = null;
368    //       DOMString                easing = "linear";
369    //       CompositeOperationOrAuto composite = "auto";
370    //     };
371    //
372    // Store the result of this procedure as keyframe output.
373    //
374    // Note: 'allow lists' is currently never true.
375    // Use the following dictionary type:
376    let Ok(keyframe_output) = BaseKeyframe::safe_from_jsval(cx, value, ()) else {
377        return Err(Error::JSFailed);
378    };
379    let ConversionResult::Success(keyframe_output) = keyframe_output else {
380        return Err(Error::Operation(None));
381    };
382
383    // From Step 2 onwards our implementation diverges from the specification. The spec
384    // wants us to build a list of animatable CSS properties and a list of properties on
385    // the object, then compute the union between the two.
386    //
387    // Instead, we iterate over all properties on the object and then check if they correspond
388    // to an animatable property.
389    let urlextradata = document.url().into_url().into();
390    let parser_context = parser_context_for_document(
391        document,
392        CssRuleType::Style,
393        ParsingMode::DEFAULT,
394        &urlextradata,
395    );
396    rooted!(&in(cx) let object = value.to_object());
397
398    // Steps 2 - 6 are in get_property_declarations
399    let declarations = get_property_declarations(cx, object.handle(), &parser_context)?;
400
401    // Step 7. Return keyframe output.
402    Ok(Keyframe {
403        offset: keyframe_output.offset,
404        easing_function: keyframe_output.easing,
405        composite: keyframe_output.composite,
406        declarations,
407    })
408}
409
410/// Implements Step 2-6 of  <https://drafts.csswg.org/web-animations-1/#process-a-keyframe-like-object>.
411#[expect(unsafe_code)]
412fn get_property_declarations(
413    cx: &mut JSContext,
414    object: HandleObject,
415    parser_context: &ParserContext<'_>,
416) -> Fallible<Vec<KeyframePropertyDeclaration>> {
417    // The spec tells us to iterate over all animatable properties and see if they're defined
418    // on the object. Instead we can iterate over the own properties of the object and see
419    // if they're animated properties, that's easier.
420    let mut ids = unsafe { IdVector::new(cx.raw_cx()) };
421    if !unsafe { GetPropertyKeys(cx, object, JSITER_OWNONLY, ids.handle_mut()) } {
422        return Ok(Vec::new());
423    }
424
425    let mut declarations = Vec::with_capacity(ids.len());
426    for id in ids.iter() {
427        rooted!(&in(cx) let id = *id);
428
429        // See if the id for the current property on the object represents a animatable CSS property.
430        if !id.is_string() {
431            continue;
432        }
433        rooted!(&in(cx) let mut key_value = UndefinedValue());
434        let raw_id: HandleId = id.handle().into();
435        if !unsafe { JS_IdToValue(cx, *raw_id.ptr, key_value.handle_mut()) } {
436            continue;
437        }
438        rooted!(&in(cx) let js_string = key_value.to_string());
439        let Some(js_string) = NonNull::new(js_string.get()) else {
440            continue;
441        };
442        let property_name = unsafe { jsstr_to_string(cx, js_string) };
443
444        let Some(property_id) = lookup_css_property_by_idl_attribute_name(&property_name) else {
445            continue;
446        };
447        debug_assert!(property_id.is_animatable());
448
449        // Step 6.1 Let raw value be the result of calling the [[Get]] internal method on keyframe input,
450        // with property name as the property key and keyframe input as the receiver.
451        // Step 6.2 Check the completion record of raw value.
452        rooted!(&in(cx) let mut property_value = UndefinedValue());
453        if !unsafe {
454            JS_GetPropertyById(
455                cx.raw_cx(),
456                object.into_handle(),
457                id.handle().into_handle(),
458                property_value.handle_mut().into_handle_mut(),
459            )
460        } {
461            continue;
462        }
463
464        // Step 6.3 Convert raw value to a DOMString or to a sequence of DOMStrings property values as follows:
465        // If allow lists is true, [..] (Note: We don't implement "allow lists")
466        // Otherwise,
467        // Let property values be the result of converting raw value to a DOMString using the procedure
468        // for converting an ECMAScript value to a DOMString [WEBIDL].
469        let property_value = match DOMString::safe_from_jsval(
470            cx,
471            property_value.handle(),
472            StringificationBehavior::Default,
473        ) {
474            Ok(ConversionResult::Success(property_value)) => property_value,
475            Ok(ConversionResult::Failure(error_message)) => {
476                return Err(Error::Operation(
477                    error_message
478                        .to_str()
479                        .ok()
480                        .map(|message| message.to_owned()),
481                ));
482            },
483            Err(_) => return Err(Error::JSFailed),
484        };
485
486        // Step 6.4 Calculate the normalized property name as the result of applying the IDL attribute name
487        // to animation property name algorithm to property name.
488        // Note: Due to the way our implementation differs from the spec (refer to the comment at
489        // the top of this function), we already have the normalized property name.
490
491        // Parse the property value as a value for the given animatable CSS property.
492        // The specification continues to treat the value as a plain string, but there's not much point.
493        let Some(declaration) =
494            parse_single_property_declaration(property_id, &property_value.str(), parser_context)
495        else {
496            continue;
497        };
498
499        // Step 6.5 Add a property to keyframe output with normalized property name as the property name,
500        // and property values as the property value.
501        declarations.push(declaration);
502    }
503
504    Ok(declarations)
505}
506
507/// Parses `input` as a value for `property`, returning `None` on failure.
508fn parse_single_property_declaration(
509    property: NonCustomPropertyId,
510    input: &str,
511    parser_context: &ParserContext<'_>,
512) -> Option<KeyframePropertyDeclaration> {
513    let mut declaration = SourcePropertyDeclaration::default();
514    let mut input = ParserInput::new(input);
515    let mut parser = Parser::new(&mut input);
516
517    // TODO: Consider reporting parse errors somewhere useful, like the devtools console.
518    parser
519        .parse_entirely(|parser| {
520            PropertyDeclaration::parse_into(
521                &mut declaration,
522                PropertyId::NonCustom(property),
523                parser_context,
524                parser,
525            )
526        })
527        .ok()?;
528
529    let mut block = PropertyDeclarationBlock::new();
530    block.extend(declaration.drain(), Importance::Normal);
531
532    Some(KeyframePropertyDeclaration {
533        property_id: PropertyId::NonCustom(property),
534        block,
535    })
536}
537
538/// This is the inverse of <https://drafts.csswg.org/web-animations-1/#animation-property-name-to-idl-attribute-name>.
539fn lookup_css_property_by_idl_attribute_name(attribute_name: &str) -> Option<NonCustomPropertyId> {
540    // TODO: Step 1. If property follows the <custom-property-name> production, return property.
541
542    // Step 2. If property refers to the CSS float property, return the string "cssFloat".
543    if attribute_name == "cssFloat" {
544        return Some(LonghandId::Float.into());
545    }
546
547    // Step 3. If property refers to the CSS offset property, return the string "cssOffset".
548    // NOTE: "offset" is not supported yet.
549
550    // Step 4. Otherwise, return the result of applying the CSS property to IDL attribute algorithm
551    // [CSSOM] to property.
552    static IDL_ATTRIBUTE_TO_ANIMATED_PROPERTY_LOOKUP_TABLE: LazyLock<
553        FxHashMap<String, NonCustomPropertyId>,
554    > = LazyLock::new(|| {
555        log::debug!("Initializing map from IDL attribute names to CSS properties");
556
557        NonCustomPropertyId::iter()
558            .filter(|non_custom_property| non_custom_property.is_animatable())
559            .filter(|non_custom_property| {
560                non_custom_property
561                    .to_property_id()
562                    .enabled_for_all_content()
563            })
564            .map(|non_custom_property| {
565                let idl_attribute_name =
566                    animation_property_name_to_idl_attribute_name(non_custom_property.name());
567                (idl_attribute_name, non_custom_property)
568            })
569            .collect()
570    });
571    IDL_ATTRIBUTE_TO_ANIMATED_PROPERTY_LOOKUP_TABLE
572        .get(attribute_name)
573        .copied()
574}
575
576/// <https://drafts.csswg.org/web-animations-1/#animation-property-name-to-idl-attribute-name>
577///
578/// # Panics
579/// Panics when the property name is empty or consists only of `-` characters.
580/// In general it is assumed that `property_name` is a CSS property.
581fn animation_property_name_to_idl_attribute_name(property_name: &str) -> String {
582    let mut idl_attribute_name = String::with_capacity(property_name.len());
583
584    let mut chunks = property_name.split('-');
585    let Some(first_chunk) = chunks.next() else {
586        unreachable!("CSS property name should not consist only of dashes");
587    };
588    idl_attribute_name.push_str(first_chunk);
589    for chunk in chunks {
590        let mut characters = chunk.chars();
591        let Some(to_capitalize) = characters.next() else {
592            continue;
593        };
594        idl_attribute_name.push(to_capitalize.to_ascii_uppercase());
595        idl_attribute_name.push_str(characters.as_str());
596    }
597
598    idl_attribute_name
599}