Browse Source

LibWeb: Implement linear easing according to latest spec

Gingeh 8 tháng trước cách đây
mục cha
commit
c67ecf37f7

+ 2 - 2
AK/Vector.h

@@ -186,12 +186,12 @@ public:
     }
 
     template<typename TUnaryPredicate>
-    Optional<VisibleType&> last_matching(TUnaryPredicate const& predicate)
+    Optional<VisibleType const&> last_matching(TUnaryPredicate const& predicate) const
     requires(!contains_reference)
     {
         for (ssize_t i = size() - 1; i >= 0; --i) {
             if (predicate(at(i))) {
-                return at(i);
+                return Optional<VisibleType const&>(at(i));
             }
         }
         return {};

+ 264 - 0
Tests/LibWeb/Text/expected/WebAnimations/misc/easing-values.txt

@@ -0,0 +1,264 @@
+linear
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+linear(0, 1)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+linear(0, 0.5, 1)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+linear(0 0%, 0.5 10%, 1 100%)
+0: 0.00
+10: 0.50
+20: 0.56
+30: 0.61
+40: 0.67
+50: 0.72
+60: 0.78
+70: 0.83
+80: 0.89
+90: 0.94
+100: 1.00
+linear(0% 0, 10% 0.5, 100% 1)
+0: 0.00
+10: 0.50
+20: 0.56
+30: 0.61
+40: 0.67
+50: 0.72
+60: 0.78
+70: 0.83
+80: 0.89
+90: 0.94
+100: 1.00
+linear(0% 0, 1 100%)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+linear(0 0% 50%, 1 50% 100%)
+0: 0.00
+10: 0.00
+20: 0.00
+30: 0.00
+40: 0.00
+50: 1.00
+60: 1.00
+70: 1.00
+80: 1.00
+90: 1.00
+100: 1.00
+linear(0.5 0% 100%)
+0: 0.50
+10: 0.50
+20: 0.50
+30: 0.50
+40: 0.50
+50: 0.50
+60: 0.50
+70: 0.50
+80: 0.50
+90: 0.50
+100: 0.50
+ease
+0: 0.00
+10: 0.09
+20: 0.30
+30: 0.51
+40: 0.68
+50: 0.80
+60: 0.89
+70: 0.94
+80: 0.98
+90: 0.99
+100: 1.00
+ease-in
+0: 0.00
+10: 0.02
+20: 0.06
+30: 0.13
+40: 0.21
+50: 0.32
+60: 0.43
+70: 0.55
+80: 0.69
+90: 0.84
+100: 1.00
+ease-out
+0: 0.00
+10: 0.16
+20: 0.31
+30: 0.45
+40: 0.57
+50: 0.68
+60: 0.79
+70: 0.87
+80: 0.94
+90: 0.98
+100: 1.00
+ease-in-out
+0: 0.00
+10: 0.02
+20: 0.08
+30: 0.19
+40: 0.33
+50: 0.50
+60: 0.67
+70: 0.81
+80: 0.92
+90: 0.98
+100: 1.00
+cubic-bezier(0, 0, 0, 0)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+cubic-bezier(1, 1, 1, 1)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+cubic-bezier(1, 1000, 1, 1000)
+0: 0.00
+10: 1.00
+20: 1.00
+30: 1.00
+40: 1.00
+50: 1.00
+60: 1.00
+70: 1.00
+80: 1.00
+90: 1.00
+100: 1.00
+step-end
+0: 0.00
+10: 0.00
+20: 0.00
+30: 0.00
+40: 0.00
+50: 0.00
+60: 0.00
+70: 0.00
+80: 0.00
+90: 0.00
+100: 1.00
+steps(1000)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+steps(10, jump-start)
+0: 0.10
+10: 0.20
+20: 0.30
+30: 0.40
+40: 0.50
+50: 0.60
+60: 0.70
+70: 0.80
+80: 0.90
+90: 1.00
+100: 1.00
+steps(10, jump-end)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00
+steps(10, jump-none)
+0: 0.00
+10: 0.11
+20: 0.22
+30: 0.33
+40: 0.44
+50: 0.56
+60: 0.67
+70: 0.78
+80: 0.89
+90: 1.00
+100: 1.00
+steps(10, jump-both)
+0: 0.09
+10: 0.18
+20: 0.27
+30: 0.36
+40: 0.45
+50: 0.55
+60: 0.64
+70: 0.73
+80: 0.82
+90: 0.91
+100: 1.00
+steps(10, end)
+0: 0.00
+10: 0.10
+20: 0.20
+30: 0.30
+40: 0.40
+50: 0.50
+60: 0.60
+70: 0.70
+80: 0.80
+90: 0.90
+100: 1.00

+ 1 - 1
Tests/LibWeb/Text/input/WebAnimations/misc/easing-parsing.html

@@ -13,6 +13,7 @@
             "linear(5% 0, 10% 0.5, 100% 1)",
             "linear(5% 0, 1 100%)",
             "linear(-14, 27 210%)",
+            "linear(0.5 5% 10%)",
             "ease",
             "ease-in",
             "ease-out",
@@ -39,7 +40,6 @@
             "linear(5 10)",
             "linear(5% 10%)",
             "linear(0.5 5% 10)",
-            "linear(0.5 5% 10%)",
             "cubic-bezier(0, 0, 0)",
             "cubic-bezier(2, 0, 0, 0)",
             "cubic-bezier(0, 0, 2, 0)",

+ 54 - 0
Tests/LibWeb/Text/input/WebAnimations/misc/easing-values.html

@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+
+<body></body>
+<script src="../../include.js"></script>
+<script>
+    test(() => {
+        const easings = [
+            "linear",
+            "linear(0, 1)",
+            "linear(0, 0.5, 1)",
+            "linear(0 0%, 0.5 10%, 1 100%)",
+            "linear(0% 0, 10% 0.5, 100% 1)",
+            "linear(0% 0, 1 100%)",
+            "linear(0 0% 50%, 1 50% 100%)",
+            "linear(0.5 0% 100%)",
+            // FIXME: "linear(-1, 2)"
+            "ease",
+            "ease-in",
+            "ease-out",
+            "ease-in-out",
+            "cubic-bezier(0, 0, 0, 0)",
+            "cubic-bezier(1, 1, 1, 1)",
+            "cubic-bezier(1, 1000, 1, 1000)",
+            // FIXME: "step-start",
+            "step-end",
+            "steps(1000)",
+            "steps(10, jump-start)",
+            "steps(10, jump-end)",
+            "steps(10, jump-none)",
+            "steps(10, jump-both)",
+            // FIXME: "steps(10, start)",
+            "steps(10, end)",
+        ];
+
+        for (const easing of easings) {
+            const target = document.createElement('div');
+            document.body.appendChild(target);
+            println(easing);
+            const animation = target.animate(
+                { opacity: ['0', '1'] },
+                {
+                    duration: 100,
+                    fill: 'forwards',
+                    easing: easing,
+                });
+            const computed_style = getComputedStyle(target);
+            for (let time = 0; time <= 100; time += 10) {
+                animation.currentTime = time;
+                println(`${time}: ${parseFloat(computed_style.opacity).toFixed(2)}`);
+            }
+            target.remove();
+        }
+    });
+</script>

+ 1 - 1
Userland/Libraries/LibWeb/Animations/AnimationEffect.h

@@ -178,7 +178,7 @@ protected:
     JS::GCPtr<Animation> m_associated_animation {};
 
     // https://www.w3.org/TR/web-animations-1/#time-transformations
-    CSS::EasingStyleValue::Function m_timing_function { CSS::EasingStyleValue::Linear {} };
+    CSS::EasingStyleValue::Function m_timing_function { CSS::EasingStyleValue::Linear::identity() };
 
     // Used for calculating transitions in StyleComputer
     Phase m_previous_phase { Phase::Idle };

+ 33 - 28
Userland/Libraries/LibWeb/CSS/Parser/Parser.cpp

@@ -6236,19 +6236,19 @@ RefPtr<CSSStyleValue> Parser::parse_easing_value(TokenStream<ComponentValue>& to
     if (part.is(Token::Type::Ident)) {
         auto name = part.token().ident();
         auto maybe_simple_easing = [&] -> RefPtr<EasingStyleValue> {
-            if (name == "linear"sv)
-                return EasingStyleValue::create(EasingStyleValue::Linear {});
-            if (name == "ease"sv)
+            if (name.equals_ignoring_ascii_case("linear"sv))
+                return EasingStyleValue::create(EasingStyleValue::Linear::identity());
+            if (name.equals_ignoring_ascii_case("ease"sv))
                 return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease());
-            if (name == "ease-in"sv)
+            if (name.equals_ignoring_ascii_case("ease-in"sv))
                 return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_in());
-            if (name == "ease-out"sv)
+            if (name.equals_ignoring_ascii_case("ease-out"sv))
                 return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_out());
-            if (name == "ease-in-out"sv)
+            if (name.equals_ignoring_ascii_case("ease-in-out"sv))
                 return EasingStyleValue::create(EasingStyleValue::CubicBezier::ease_in_out());
-            if (name == "step-start"sv)
+            if (name.equals_ignoring_ascii_case("step-start"sv))
                 return EasingStyleValue::create(EasingStyleValue::Steps::step_start());
-            if (name == "step-end"sv)
+            if (name.equals_ignoring_ascii_case("step-end"sv))
                 return EasingStyleValue::create(EasingStyleValue::Steps::step_end());
             return {};
         }();
@@ -6271,33 +6271,38 @@ RefPtr<CSSStyleValue> Parser::parse_easing_value(TokenStream<ComponentValue>& to
         argument.remove_all_matching([](auto& value) { return value.is(Token::Type::Whitespace); });
 
     auto name = part.function().name;
-    if (name == "linear"sv) {
+    if (name.equals_ignoring_ascii_case("linear"sv)) {
+        // linear() = linear( [ <number> && <percentage>{0,2} ]# )
         Vector<EasingStyleValue::Linear::Stop> stops;
         for (auto const& argument : comma_separated_arguments) {
-            if (argument.is_empty() || argument.size() > 2)
-                return nullptr;
+            TokenStream argument_tokens { argument };
 
-            Optional<double> offset;
-            Optional<double> position;
+            Optional<double> output;
+            Optional<double> first_input;
+            Optional<double> second_input;
 
-            for (auto const& part : argument) {
-                if (part.is(Token::Type::Number)) {
-                    if (offset.has_value())
-                        return nullptr;
-                    offset = part.token().number_value();
-                } else if (part.is(Token::Type::Percentage)) {
-                    if (position.has_value())
-                        return nullptr;
-                    position = part.token().percentage();
-                } else {
+            if (argument_tokens.next_token().is(Token::Type::Number))
+                output = argument_tokens.consume_a_token().token().number_value();
+
+            if (argument_tokens.next_token().is(Token::Type::Percentage)) {
+                first_input = argument_tokens.consume_a_token().token().percentage() / 100;
+                if (argument_tokens.next_token().is(Token::Type::Percentage)) {
+                    second_input = argument_tokens.consume_a_token().token().percentage() / 100;
+                }
+            }
+
+            if (argument_tokens.next_token().is(Token::Type::Number)) {
+                if (output.has_value())
                     return nullptr;
-                };
+                output = argument_tokens.consume_a_token().token().number_value();
             }
 
-            if (!offset.has_value())
+            if (argument_tokens.has_next_token() || !output.has_value())
                 return nullptr;
 
-            stops.append({ offset.value(), move(position) });
+            stops.append({ output.value(), first_input, first_input.has_value() });
+            if (second_input.has_value())
+                stops.append({ output.value(), second_input, true });
         }
 
         if (stops.is_empty())
@@ -6307,7 +6312,7 @@ RefPtr<CSSStyleValue> Parser::parse_easing_value(TokenStream<ComponentValue>& to
         return EasingStyleValue::create(EasingStyleValue::Linear { move(stops) });
     }
 
-    if (name == "cubic-bezier") {
+    if (name.equals_ignoring_ascii_case("cubic-bezier"sv)) {
         if (comma_separated_arguments.size() != 4)
             return nullptr;
 
@@ -6332,7 +6337,7 @@ RefPtr<CSSStyleValue> Parser::parse_easing_value(TokenStream<ComponentValue>& to
         return EasingStyleValue::create(bezier);
     }
 
-    if (name == "steps") {
+    if (name.equals_ignoring_ascii_case("steps"sv)) {
         if (comma_separated_arguments.is_empty() || comma_separated_arguments.size() > 2)
             return nullptr;
 

+ 2 - 0
Userland/Libraries/LibWeb/CSS/StyleComputer.cpp

@@ -994,6 +994,8 @@ void StyleComputer::collect_animation_into(DOM::Element& element, Optional<CSS::
 
     auto& keyframes = effect->key_frame_set()->keyframes_by_key;
 
+    // FIXME: Support progress values outside [0-1]
+    output_progress = clamp(output_progress.value(), 0, 1);
     auto key = static_cast<u64>(output_progress.value() * 100.0 * Animations::KeyframeEffect::AnimationKeyFrameKeyScaleFactor);
     auto matching_keyframe_it = keyframes.find_largest_not_above_iterator(key);
     if (matching_keyframe_it.is_end()) {

+ 145 - 14
Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.cpp

@@ -14,6 +14,13 @@
 
 namespace Web::CSS {
 
+// https://drafts.csswg.org/css-easing-1/#valdef-easing-function-linear
+EasingStyleValue::Linear EasingStyleValue::Linear::identity()
+{
+    static Linear linear { { { 0, {}, false }, { 1, {}, false } } };
+    return linear;
+}
+
 // NOTE: Magic cubic bezier values from https://www.w3.org/TR/css-easing-1/#valdef-cubic-bezier-easing-function-ease
 
 EasingStyleValue::CubicBezier EasingStyleValue::CubicBezier::ease()
@@ -57,30 +64,154 @@ bool EasingStyleValue::CubicBezier::operator==(Web::CSS::EasingStyleValue::Cubic
     return x1 == other.x1 && y1 == other.y1 && x2 == other.x2 && y2 == other.y2;
 }
 
-double EasingStyleValue::Linear::evaluate_at(double input_progress, bool) const
+// https://drafts.csswg.org/css-easing/#linear-canonicalization
+EasingStyleValue::Linear::Linear(Vector<EasingStyleValue::Linear::Stop> stops)
 {
-    return input_progress;
+    // To canonicalize a linear() function’s control points, perform the following:
+
+    // 1. If the first control point lacks an input progress value, set its input progress value to 0.
+    if (!stops.first().input.has_value())
+        stops.first().input = 0;
+
+    // 2. If the last control point lacks an input progress value, set its input progress value to 1.
+    if (!stops.last().input.has_value())
+        stops.last().input = 1;
+
+    // 3. If any control point has an input progress value that is less than
+    // the input progress value of any preceding control point,
+    // set its input progress value to the largest input progress value of any preceding control point.
+    double largest_input = 0;
+    for (auto stop : stops) {
+        if (stop.input.has_value()) {
+            if (stop.input.value() < largest_input) {
+                stop.input = largest_input;
+            } else {
+                largest_input = stop.input.value();
+            }
+        }
+    }
+
+    // 4. If any control point still lacks an input progress value,
+    // then for each contiguous run of such control points,
+    // set their input progress values so that they are evenly spaced
+    // between the preceding and following control points with input progress values.
+    Optional<size_t> run_start_idx;
+    for (size_t idx = 0; idx < stops.size(); idx++) {
+        auto stop = stops[idx];
+        if (stop.input.has_value() && run_start_idx.has_value()) {
+            // Note: this stop is immediately after a run
+            //       set inputs of [start, idx-1] stops to be evenly spaced between start-1 and idx
+            auto start_input = stops[run_start_idx.value() - 1].input.value();
+            auto end_input = stops[idx].input.value();
+            auto run_stop_count = idx - run_start_idx.value() + 1;
+            auto delta = (end_input - start_input) / run_stop_count;
+            for (size_t run_idx = 0; run_idx < run_stop_count; run_idx++) {
+                stops[run_idx + run_start_idx.value() - 1].input = start_input + delta * run_idx;
+            }
+            run_start_idx = {};
+        } else if (!stop.input.has_value() && !run_start_idx.has_value()) {
+            // Note: this stop is the start of a run
+            run_start_idx = idx;
+        }
+    }
+
+    this->stops = move(stops);
 }
 
+// https://drafts.csswg.org/css-easing/#linear-easing-function-output
+double EasingStyleValue::Linear::evaluate_at(double input_progress, bool before_flag) const
+{
+    // To calculate linear easing output progress for a given linear easing function func,
+    // an input progress value inputProgress, and an optional before flag (defaulting to false),
+    // perform the following:
+
+    // 1. Let points be func’s control points.
+    // 2. If points holds only a single item, return the output progress value of that item.
+    if (stops.size() == 1)
+        return stops[0].output;
+
+    // 3. If inputProgress matches the input progress value of the first point in points,
+    // and the before flag is true, return the first point’s output progress value.
+    if (input_progress == stops[0].input.value() && before_flag)
+        return stops[0].output;
+
+    // 4. If inputProgress matches the input progress value of at least one point in points,
+    // return the output progress value of the last such point.
+    auto maybe_match = stops.last_matching([&](auto& stop) { return input_progress == stop.input.value(); });
+    if (maybe_match.has_value())
+        return maybe_match->output;
+
+    // 5. Otherwise, find two control points in points, A and B, which will be used for interpolation:
+    Stop A;
+    Stop B;
+
+    if (input_progress < stops[0].input.value()) {
+        // 1. If inputProgress is smaller than any input progress value in points,
+        // let A and B be the first two items in points.
+        // If A and B have the same input progress value, return A’s output progress value.
+        A = stops[0];
+        B = stops[1];
+        if (A.input == B.input)
+            return A.output;
+    } else if (input_progress > stops.last().input.value()) {
+        // 2. If inputProgress is larger than any input progress value in points,
+        // let A and B be the last two items in points.
+        // If A and B have the same input progress value, return B’s output progress value.
+        A = stops[stops.size() - 2];
+        B = stops[stops.size() - 1];
+        if (A.input == B.input)
+            return B.output;
+    } else {
+        // 3. Otherwise, let A be the last control point whose input progress value is smaller than inputProgress,
+        // and let B be the first control point whose input progress value is larger than inputProgress.
+        A = stops.last_matching([&](auto& stop) { return stop.input.value() < input_progress; }).value();
+        B = stops.first_matching([&](auto& stop) { return stop.input.value() > input_progress; }).value();
+    }
+
+    // 6. Linearly interpolate (or extrapolate) inputProgress along the line defined by A and B, and return the result.
+    auto factor = (input_progress - A.input.value()) / (B.input.value() - A.input.value());
+    return A.output + factor * (B.output - A.output);
+}
+
+// https://drafts.csswg.org/css-easing/#linear-easing-function-serializing
 String EasingStyleValue::Linear::to_string() const
 {
+    // The linear keyword is serialized as itself.
+    if (*this == identity())
+        return "linear"_string;
+
+    // To serialize a linear() function:
+    // 1. Let s be the string "linear(".
     StringBuilder builder;
-    builder.append("linear"sv);
-    if (!stops.is_empty()) {
-        builder.append('(');
-
-        bool first = true;
-        for (auto const& stop : stops) {
-            if (!first)
-                builder.append(", "sv);
+    builder.append("linear("sv);
+
+    // 2. Serialize each control point of the function,
+    // concatenate the results using the separator ", ",
+    // and append the result to s.
+    bool first = true;
+    for (auto stop : stops) {
+        if (first) {
             first = false;
-            builder.appendff("{}"sv, stop.offset);
-            if (stop.position.has_value())
-                builder.appendff(" {}"sv, stop.position.value());
+        } else {
+            builder.append(", "sv);
         }
 
-        builder.append(')');
+        // To serialize a linear() control point:
+        // 1. Let s be the serialization, as a <number>, of the control point’s output progress value.
+        builder.appendff("{}", stop.output);
+
+        // 2. If the control point originally lacked an input progress value, return s.
+        // 3. Otherwise, append " " (U+0020 SPACE) to s,
+        // then serialize the control point’s input progress value as a <percentage> and append it to s.
+        if (stop.had_explicit_input) {
+            builder.appendff(" {}%", stop.input.value() * 100);
+        }
+
+        // 4. Return s.
     }
+
+    // 4. Append ")" to s, and return it.
+    builder.append(')');
     return MUST(builder.to_string());
 }
 

+ 10 - 2
Userland/Libraries/LibWeb/CSS/StyleValues/EasingStyleValue.h

@@ -17,9 +17,15 @@ namespace Web::CSS {
 class EasingStyleValue final : public StyleValueWithDefaultOperators<EasingStyleValue> {
 public:
     struct Linear {
+        static Linear identity();
+
         struct Stop {
-            double offset;
-            Optional<double> position;
+            double output;
+            Optional<double> input;
+
+            // "NOTE: Serialization relies on whether or not an input progress value was originally supplied,
+            // so that information should be retained in the internal representation."
+            bool had_explicit_input;
 
             bool operator==(Stop const&) const = default;
         };
@@ -30,6 +36,8 @@ public:
 
         double evaluate_at(double input_progress, bool before_flag) const;
         String to_string() const;
+
+        Linear(Vector<Stop> stops);
     };
 
     struct CubicBezier {