ladybird/Libraries/LibWeb/Animations/ScrollTimeline.cpp

266 lines
11 KiB
C++
Raw Permalink Normal View History

/*
* 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();
// 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();
}
}