Files
ladybird/Libraries/LibWeb/Animations/ScrollTimeline.cpp
Shannon Booth 5adfd1c43a LibWeb/Bindings: Generate struct definitions from IDL dictionaries
Previously we were inconsistent by generating code for enum definitions
but not generating code for dictionaries. With future changes to the
IDL generator to expose helpers to convert to and from IDL values
this produced circular depdendencies. To solve this problem, also
generate the dictionary definitions in bindings headers.
2026-05-09 10:49:49 +02:00

266 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) 2026, Callum Law <callumlaw1709@outlook.com>.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include "ScrollTimeline.h"
#include <LibWeb/Animations/Animation.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Layout/Viewport.h>
#include <LibWeb/Painting/PaintableBox.h>
namespace Web::Animations {
GC_DEFINE_ALLOCATOR(ScrollTimeline);
GC::Ref<ScrollTimeline> ScrollTimeline::create(JS::Realm& realm, DOM::Document& document, Source source, Bindings::ScrollAxis axis)
{
auto timeline = realm.create<ScrollTimeline>(realm, document, source, axis);
// NB: The passed timestamp is ignored for ScrollTimelines so we can just pass 0 here.
timeline->update_current_time(0);
return timeline;
}
// https://drafts.csswg.org/scroll-animations-1/#dom-scrolltimeline-scrolltimeline
GC::Ref<ScrollTimeline> ScrollTimeline::construct_impl(JS::Realm& realm, Bindings::ScrollTimelineOptions options)
{
auto& document = as<HTML::Window>(realm.global_object()).associated_document();
// 1. Let timeline be the new ScrollTimeline object.
// 2. Set the source of timeline to:
auto source = [&]() -> GC::Ptr<DOM::Element const> {
// If the source member of options is present,
// The source member of options.
if (options.source.has_value())
return options.source.value().ptr();
// Otherwise,
// The scrollingElement of the Document associated with the Window that is the current global object.
if (document.scrolling_element())
return document.scrolling_element();
return nullptr;
}();
// 3. Set the axis property of timeline to the corresponding value from options.
return create(realm, document, source, options.axis);
}
GC::Ptr<DOM::Element const> ScrollTimeline::source() const
{
return m_source.visit(
[](GC::Ptr<DOM::Element const> const& source) -> GC::Ptr<DOM::Element const> {
return source;
},
[](AnonymousSource const& anonymous_source) -> GC::Ptr<DOM::Element const> {
switch (anonymous_source.scroller) {
case CSS::Scroller::Root:
return anonymous_source.target.document().document_element();
case CSS::Scroller::Nearest: {
GC::Ptr<DOM::Element const> ancestor = anonymous_source.target.parent_element();
while (ancestor && !ancestor->is_scroll_container())
ancestor = ancestor->parent_element();
return ancestor;
}
case CSS::Scroller::Self:
return anonymous_source.target.element();
}
VERIFY_NOT_REACHED();
});
}
struct ComputedScrollAxis {
bool is_vertical;
bool is_reversed;
};
static ComputedScrollAxis computed_scroll_axis(Bindings::ScrollAxis axis, CSS::WritingMode writing_mode, CSS::Direction direction)
{
// NB: This is based on the table specified here: https://drafts.csswg.org/css-writing-modes-4/#logical-to-physical
// FIXME: Note: The used direction depends on the computed writing-mode and text-orientation: in vertical writing
// modes, a text-orientation value of upright forces the used direction to ltr.
auto used_direction = direction;
switch (axis) {
case Bindings::ScrollAxis::Block:
switch (writing_mode) {
case CSS::WritingMode::HorizontalTb:
return { true, false };
case CSS::WritingMode::VerticalRl:
case CSS::WritingMode::SidewaysRl:
return { false, true };
case CSS::WritingMode::VerticalLr:
case CSS::WritingMode::SidewaysLr:
return { false, false };
}
VERIFY_NOT_REACHED();
case Bindings::ScrollAxis::Inline:
switch (writing_mode) {
case CSS::WritingMode::HorizontalTb:
return { false, used_direction == CSS::Direction::Rtl };
case CSS::WritingMode::VerticalRl:
case CSS::WritingMode::SidewaysRl:
case CSS::WritingMode::VerticalLr:
return { true, used_direction == CSS::Direction::Rtl };
case CSS::WritingMode::SidewaysLr:
return { true, used_direction == CSS::Direction::Ltr };
}
VERIFY_NOT_REACHED();
case Bindings::ScrollAxis::X:
return { false, false };
case Bindings::ScrollAxis::Y:
return { true, false };
}
VERIFY_NOT_REACHED();
}
struct ScrollOffsetData {
double scroll_offset;
double max_scroll_offset;
};
static Optional<ScrollOffsetData> compute_scroll_offset_data(Variant<GC::Ptr<DOM::Element const>, GC::Ptr<DOM::Document>> propagated_source, Bindings::ScrollAxis axis)
{
if (propagated_source.visit([](auto const& source) { return source == nullptr; }))
return {};
auto const& layout_node = propagated_source.visit([](auto const& source) -> Layout::NodeWithStyle const* { return source->unsafe_layout_node(); });
if (!layout_node || !layout_node->is_scroll_container())
return {};
auto const& paintable_box = propagated_source.visit([](auto const& source) -> RefPtr<Painting::PaintableBox const> { return source->unsafe_paintable_box(); });
if (!paintable_box || !paintable_box->has_scrollable_overflow())
return {};
auto const& scrollable_overflow_rect = paintable_box->scrollable_overflow_rect().value();
auto const& computed_axis = computed_scroll_axis(axis, paintable_box->computed_values().writing_mode(), paintable_box->computed_values().direction());
// FIXME: Scroll offset is currently incorrect as it is always relative to the top left of the scrollable overflow
// rect when it should instead be relative to the scroll origin.
// FIXME: Support the case where the computed scroll axis is reversed
return ScrollOffsetData {
.scroll_offset = computed_axis.is_vertical
? paintable_box->scroll_offset().y().to_double()
: paintable_box->scroll_offset().x().to_double(),
.max_scroll_offset = computed_axis.is_vertical
? scrollable_overflow_rect.height().to_double() - paintable_box->content_height().to_double()
: scrollable_overflow_rect.width().to_double() - paintable_box->content_width().to_double(),
};
}
bool ScrollTimeline::is_stale() const
{
// FIXME: This should probably be a spec bug
// https://drafts.csswg.org/scroll-animations-1/#stale-timelines
// AD-HOC: The spec only lists two criteria for a scroll timeline to be considered stale: a) it was newly created
// within the current frame or; b) style update and layout within the current frame changed it's associated
// named timeline ranges. However, WPT expects us to also consider the source element resizing as a cause
// for staleness, so we use a more broad definition of staleness and consider any timeline whose max scroll
// offset has changed since the last call to `update_current_time` to be stale. This matches the (admittedly
// narrow) tests in WPT and is the same behavior as is implemented by WebKit.
// FIXME: Account for the named timeline ranges of view progress timelines once they are implemented.
auto scroll_offset_data = compute_scroll_offset_data(get_propagated_source(), m_axis);
return scroll_offset_data.map([](auto const& data) { return data.max_scroll_offset; }) != m_last_max_scroll_offset;
}
void ScrollTimeline::update_current_time(double)
{
// https://drafts.csswg.org/scroll-animations-1/#ref-for-dom-animationtimeline-currenttime
// currentTime represents the scroll progress of the scroll container as a percentage CSSUnitValue, with 0%
// representing its startmost scroll position (in the writing mode of the scroll container). Null when the timeline
// is inactive.
auto scroll_offset_data = compute_scroll_offset_data(get_propagated_source(), m_axis);
m_last_max_scroll_offset = scroll_offset_data.map([](auto const& data) { return data.max_scroll_offset; });
// If the source of a ScrollTimeline is an element whose principal box does not exist or is not a scroll container,
// or if there is no scrollable overflow, then the ScrollTimeline is inactive.
// NB: Called during animation timeline update, which runs before layout is up to date.
if (!scroll_offset_data.has_value()) {
set_current_time({});
return;
}
// https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-progress
// If the 0% position and 100% position coincide (i.e. the denominator in the current time formula is zero), the timeline is inactive.
if (scroll_offset_data->max_scroll_offset == 0) {
set_current_time({});
return;
}
// FIXME: In paged media, scroll progress timelines that would otherwise reference the document viewport are also inactive.
// https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-progress
// Progress (the current time) for a scroll progress timeline is calculated as:
// scroll offset ÷ (scrollable overflow size scroll container size)
auto progress = scroll_offset_data->scroll_offset / scroll_offset_data->max_scroll_offset;
set_current_time(TimeValue { TimeValue::Type::Percentage, progress * 100 });
}
ScrollTimeline::ScrollTimeline(JS::Realm& realm, DOM::Document& document, Source source, Bindings::ScrollAxis axis)
: AnimationTimeline(realm, document)
, m_source(source)
, m_axis(axis)
{
}
void ScrollTimeline::visit_edges(Cell::Visitor& visitor)
{
Base::visit_edges(visitor);
m_source.visit(
[&](GC::Ptr<DOM::Element const>& source) { visitor.visit(source); },
[&](AnonymousSource& anonymous_source) { anonymous_source.target.visit(visitor); });
}
void ScrollTimeline::initialize(JS::Realm& realm)
{
WEB_SET_PROTOTYPE_FOR_INTERFACE(ScrollTimeline);
Base::initialize(realm);
}
Variant<GC::Ptr<DOM::Element const>, GC::Ptr<DOM::Document>> ScrollTimeline::get_propagated_source() const
{
auto const& source = this->source();
// https://drafts.csswg.org/scroll-animations-1/#scroll-notation
// References to the root element propagate to the document viewport (which functions as its scroll container).
if (source && source == source->document().document_element())
return source->owner_document();
return source;
}
Bindings::ScrollAxis css_axis_to_bindings_scroll_axis(CSS::Axis axis)
{
switch (axis) {
case CSS::Axis::Block:
return Bindings::ScrollAxis::Block;
case CSS::Axis::Inline:
return Bindings::ScrollAxis::Inline;
case CSS::Axis::X:
return Bindings::ScrollAxis::X;
case CSS::Axis::Y:
return Bindings::ScrollAxis::Y;
}
VERIFY_NOT_REACHED();
}
}