Bump a page-level wheel listener generation whenever non-passive wheel listeners are added or removed. Store that generation in async scrolling snapshots, and mark compositor wheel admission stale when listener state changes before a current snapshot arrives. This keeps the UI-process compositor wheel bypass from trusting an old no-listener snapshot after script has installed a cancelable wheel listener. Add async-scrolling coverage for the listener generation bump and the fresh routing state after a blocking wheel listener is added.
320 lines
16 KiB
C++
320 lines
16 KiB
C++
/*
|
|
* Copyright (c) 2026-present, the Ladybird developers.
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <LibWeb/Compositor/AsyncScrollingState.h>
|
|
#include <LibWeb/DOM/DOMEventListener.h>
|
|
#include <LibWeb/DOM/Document.h>
|
|
#include <LibWeb/DOM/EventTarget.h>
|
|
#include <LibWeb/HTML/Navigable.h>
|
|
#include <LibWeb/HTML/NavigableContainer.h>
|
|
#include <LibWeb/HTML/Window.h>
|
|
#include <LibWeb/Page/Page.h>
|
|
#include <LibWeb/Painting/DisplayList.h>
|
|
#include <LibWeb/Painting/PaintableBox.h>
|
|
#include <LibWeb/Painting/ScrollState.h>
|
|
#include <LibWeb/Painting/ViewportPaintable.h>
|
|
#include <LibWeb/PixelUnits.h>
|
|
#include <LibWeb/TraversalDecision.h>
|
|
|
|
namespace Web::Compositor {
|
|
|
|
static Gfx::FloatPoint css_point_to_device_point(CSSPixelPoint point, double device_pixels_per_css_pixel)
|
|
{
|
|
auto scale = static_cast<float>(device_pixels_per_css_pixel);
|
|
return { point.x().to_float() * scale, point.y().to_float() * scale };
|
|
}
|
|
|
|
static Gfx::FloatSize css_size_to_device_size(CSSPixelSize size, double device_pixels_per_css_pixel)
|
|
{
|
|
auto scale = static_cast<float>(device_pixels_per_css_pixel);
|
|
return { size.width().to_float() * scale, size.height().to_float() * scale };
|
|
}
|
|
|
|
static Gfx::FloatRect css_rect_to_device_rect(CSSPixelRect rect, double device_pixels_per_css_pixel)
|
|
{
|
|
return { css_point_to_device_point(rect.location(), device_pixels_per_css_pixel), css_size_to_device_size(rect.size(), device_pixels_per_css_pixel) };
|
|
}
|
|
|
|
static Optional<float> css_inset_to_device_inset(Optional<CSSPixels> inset, double device_pixels_per_css_pixel)
|
|
{
|
|
if (!inset.has_value())
|
|
return {};
|
|
return inset->to_float() * static_cast<float>(device_pixels_per_css_pixel);
|
|
}
|
|
|
|
static CSSPixelPoint maximum_scroll_offset_for(Painting::PaintableBox const& paintable_box)
|
|
{
|
|
CSSPixelPoint max_scroll_offset;
|
|
auto scrollable_overflow_rect = paintable_box.scrollable_overflow_rect();
|
|
if (!scrollable_overflow_rect.has_value())
|
|
return max_scroll_offset;
|
|
|
|
auto scrollport_rect = paintable_box.absolute_padding_box_rect();
|
|
max_scroll_offset.set_x(max(CSSPixels(0), scrollable_overflow_rect->width() - scrollport_rect.width()));
|
|
max_scroll_offset.set_y(max(CSSPixels(0), scrollable_overflow_rect->height() - scrollport_rect.height()));
|
|
return max_scroll_offset;
|
|
}
|
|
|
|
static AsyncScrollNodeID scroll_node_id_for(UniqueNodeID document_id, Painting::ScrollFrameIndex scroll_frame_index)
|
|
{
|
|
return { .document_id = document_id, .scroll_frame_index = scroll_frame_index };
|
|
}
|
|
|
|
static Optional<AsyncScrollNodeID> scroll_node_id_for_scroll_frame_index(AsyncScrollingState const& async_scrolling_state, Painting::ScrollFrameIndex scroll_frame_index)
|
|
{
|
|
for (auto const& scroll_node : async_scrolling_state.scroll_nodes) {
|
|
if (scroll_node.node_id.scroll_frame_index == scroll_frame_index)
|
|
return scroll_node.node_id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
static Optional<AsyncScrollNodeID> parent_scroll_node_id_for(AsyncScrollingState const& async_scrolling_state, Painting::ScrollState const& scroll_state, Painting::ScrollFrameIndex parent_scroll_frame_index)
|
|
{
|
|
for (auto scroll_frame_index = parent_scroll_frame_index; scroll_frame_index.value(); scroll_frame_index = scroll_state.frame_at(scroll_frame_index).parent_index()) {
|
|
if (auto node_id = scroll_node_id_for_scroll_frame_index(async_scrolling_state, scroll_frame_index); node_id.has_value())
|
|
return node_id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
static bool has_blocking_wheel_event_listener(DOM::EventTarget& event_target)
|
|
{
|
|
for (auto listener : event_target.event_listener_list()) {
|
|
if (AK::first_is_one_of(listener->type, "wheel"sv, "mousewheel"sv) && listener->passive != true)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool is_root_wheel_event_target(DOM::Node& node)
|
|
{
|
|
auto& document = node.document();
|
|
return &node == document.document_element() || &node == document.body();
|
|
}
|
|
|
|
static void collect_root_blocking_wheel_event_regions(AsyncScrollingState& async_scrolling_state, DOM::Document& document)
|
|
{
|
|
if (auto window = document.navigable() ? document.navigable()->active_window() : nullptr; window && has_blocking_wheel_event_listener(*window)) {
|
|
async_scrolling_state.has_blocking_wheel_event_listeners = true;
|
|
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
|
|
}
|
|
if (has_blocking_wheel_event_listener(document)) {
|
|
async_scrolling_state.has_blocking_wheel_event_listeners = true;
|
|
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
|
|
}
|
|
if (auto* document_element = document.document_element(); document_element && has_blocking_wheel_event_listener(*document_element)) {
|
|
async_scrolling_state.has_blocking_wheel_event_listeners = true;
|
|
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
|
|
}
|
|
if (auto* body = document.body(); body && has_blocking_wheel_event_listener(*body)) {
|
|
async_scrolling_state.has_blocking_wheel_event_listeners = true;
|
|
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
|
|
}
|
|
}
|
|
|
|
AsyncScrollingState collect_async_scrolling_state(HTML::Navigable& navigable, Painting::ViewportPaintable& document_paintable, Gfx::IntRect viewport_rect)
|
|
{
|
|
auto device_pixels_per_css_pixel = navigable.page().client().device_pixels_per_css_pixel();
|
|
auto const& scroll_state = document_paintable.scroll_state();
|
|
auto document_id = document_paintable.document().unique_id();
|
|
AsyncScrollingState async_scrolling_state;
|
|
Vector<Painting::ScrollFrameIndex> parent_scroll_frame_indices;
|
|
async_scrolling_state.viewport_rect = viewport_rect;
|
|
async_scrolling_state.wheel_event_listener_state_generation = navigable.page().wheel_event_listener_state_generation();
|
|
collect_root_blocking_wheel_event_regions(async_scrolling_state, document_paintable.document());
|
|
|
|
document_paintable.for_each_in_inclusive_subtree_of_type<Painting::PaintableBox>([&](auto& paintable_box) {
|
|
if (!async_scrolling_state.has_blocking_wheel_event_region_covering_viewport) {
|
|
if (auto node = paintable_box.dom_node(); node && !is_root_wheel_event_target(*node) && has_blocking_wheel_event_listener(*node)) {
|
|
async_scrolling_state.has_blocking_wheel_event_listeners = true;
|
|
async_scrolling_state.blocking_wheel_event_regions.append({
|
|
.visual_context_index = paintable_box.accumulated_visual_context_index(),
|
|
.rect = css_rect_to_device_rect(paintable_box.absolute_united_border_box_rect(), device_pixels_per_css_pixel),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (auto node = paintable_box.dom_node(); node && node->is_navigable_container() && as<HTML::NavigableContainer>(*node).content_navigable()) {
|
|
async_scrolling_state.main_thread_wheel_event_regions.append({
|
|
.visual_context_index = paintable_box.accumulated_visual_context_index(),
|
|
.rect = css_rect_to_device_rect(paintable_box.absolute_united_border_box_rect(), device_pixels_per_css_pixel),
|
|
});
|
|
}
|
|
|
|
auto sticky_frame_index = paintable_box.enclosing_scroll_frame_index();
|
|
if (paintable_box.is_sticky_position() && sticky_frame_index.value()) {
|
|
auto const& frame = scroll_state.frame_at(sticky_frame_index);
|
|
if (frame.is_sticky() && frame.has_sticky_constraints()) {
|
|
auto const& constraints = frame.sticky_constraints();
|
|
auto const& insets = constraints.insets;
|
|
async_scrolling_state.sticky_areas.append({
|
|
.document_id = document_id,
|
|
.scroll_frame_index = sticky_frame_index,
|
|
.parent_scroll_frame_index = frame.parent_index(),
|
|
.nearest_scrolling_ancestor_index = scroll_state.nearest_scrolling_ancestor(sticky_frame_index),
|
|
.position_relative_to_scroll_ancestor = css_point_to_device_point(constraints.position_relative_to_scroll_ancestor, device_pixels_per_css_pixel),
|
|
.border_box_size = css_size_to_device_size(constraints.border_box_size, device_pixels_per_css_pixel),
|
|
.scrollport_size = css_size_to_device_size(constraints.scrollport_size, device_pixels_per_css_pixel),
|
|
.containing_block_region = css_rect_to_device_rect(constraints.containing_block_region, device_pixels_per_css_pixel),
|
|
.needs_parent_offset_adjustment = constraints.needs_parent_offset_adjustment,
|
|
.inset_top = css_inset_to_device_inset(insets.top, device_pixels_per_css_pixel),
|
|
.inset_right = css_inset_to_device_inset(insets.right, device_pixels_per_css_pixel),
|
|
.inset_bottom = css_inset_to_device_inset(insets.bottom, device_pixels_per_css_pixel),
|
|
.inset_left = css_inset_to_device_inset(insets.left, device_pixels_per_css_pixel),
|
|
});
|
|
}
|
|
}
|
|
|
|
auto scroll_frame_index = paintable_box.own_scroll_frame_index();
|
|
if (scroll_frame_index.value() && paintable_box.could_be_scrolled_by_wheel_event()) {
|
|
auto parent_scroll_frame_index = scroll_state.frame_at(scroll_frame_index).parent_index();
|
|
async_scrolling_state.scroll_nodes.append({
|
|
.node_id = scroll_node_id_for(document_id, scroll_frame_index),
|
|
.parent_node_id = {},
|
|
.hit_test_visual_context_index = paintable_box.accumulated_visual_context_index(),
|
|
.scrollport_rect = navigable.page().css_to_device_rect(paintable_box.absolute_padding_box_rect()).template to_type<int>(),
|
|
.scroll_offset = css_point_to_device_point(paintable_box.scroll_offset(), device_pixels_per_css_pixel),
|
|
.max_scroll_offset = css_point_to_device_point(maximum_scroll_offset_for(paintable_box), device_pixels_per_css_pixel),
|
|
.is_viewport = paintable_box.is_viewport_paintable(),
|
|
});
|
|
parent_scroll_frame_indices.append(parent_scroll_frame_index);
|
|
}
|
|
|
|
return TraversalDecision::Continue;
|
|
});
|
|
|
|
VERIFY(parent_scroll_frame_indices.size() == async_scrolling_state.scroll_nodes.size());
|
|
for (size_t i = 0; i < async_scrolling_state.scroll_nodes.size(); ++i)
|
|
async_scrolling_state.scroll_nodes[i].parent_node_id = parent_scroll_node_id_for(async_scrolling_state, scroll_state, parent_scroll_frame_indices[i]);
|
|
|
|
async_scrolling_state.blocking_wheel_event_regions_are_current = async_scrolling_state.has_blocking_wheel_event_listeners;
|
|
return async_scrolling_state;
|
|
}
|
|
|
|
WheelRoutingAdmission wheel_routing_admission_for(AsyncScrollingState const& state)
|
|
{
|
|
if (state.has_blocking_wheel_event_listeners)
|
|
return WheelRoutingAdmission::BlockingWheelEventListeners;
|
|
|
|
bool found_viewport_node = false;
|
|
for (auto const& node : state.scroll_nodes) {
|
|
if (node.is_viewport) {
|
|
found_viewport_node = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found_viewport_node)
|
|
return WheelRoutingAdmission::NoViewportScrollNode;
|
|
return WheelRoutingAdmission::Accepted;
|
|
}
|
|
|
|
StringView wheel_routing_admission_to_string(WheelRoutingAdmission admission)
|
|
{
|
|
switch (admission) {
|
|
case WheelRoutingAdmission::Accepted:
|
|
return "accepted"sv;
|
|
case WheelRoutingAdmission::NoAsyncScrollingState:
|
|
return "no async scrolling state"sv;
|
|
case WheelRoutingAdmission::BlockingWheelEventListeners:
|
|
return "blocking wheel event listeners"sv;
|
|
case WheelRoutingAdmission::NoViewportScrollNode:
|
|
return "no viewport scroll node"sv;
|
|
case WheelRoutingAdmission::StaleWheelEventListeners:
|
|
return "stale wheel event listeners"sv;
|
|
}
|
|
VERIFY_NOT_REACHED();
|
|
}
|
|
|
|
bool blocks_wheel_event_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position)
|
|
{
|
|
if (async_scrolling_state.has_blocking_wheel_event_region_covering_viewport)
|
|
return true;
|
|
|
|
// If a caller knows blocking wheel listeners exist but cannot provide a display list for visual-context hit
|
|
// testing, async scrolling must fail closed. Sending the input to the main thread is slower, but it preserves
|
|
// cancelability.
|
|
if (!display_list)
|
|
return async_scrolling_state.has_blocking_wheel_event_listeners;
|
|
|
|
auto const& visual_context_tree = display_list->visual_context_tree();
|
|
for (auto const& region : async_scrolling_state.blocking_wheel_event_regions) {
|
|
auto position_in_context = visual_context_tree.transform_point_for_hit_test(region.visual_context_index, position, scroll_state_snapshot);
|
|
if (position_in_context.has_value() && region.rect.contains(*position_in_context))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool requires_main_thread_wheel_event_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position)
|
|
{
|
|
if (async_scrolling_state.main_thread_wheel_event_regions.is_empty())
|
|
return false;
|
|
|
|
if (!display_list)
|
|
return true;
|
|
|
|
auto const& visual_context_tree = display_list->visual_context_tree();
|
|
for (auto const& region : async_scrolling_state.main_thread_wheel_event_regions) {
|
|
auto position_in_context = visual_context_tree.transform_point_for_hit_test(region.visual_context_index, position, scroll_state_snapshot);
|
|
if (position_in_context.has_value() && region.rect.contains(*position_in_context))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool scroll_node_can_scroll_by_delta(AsyncScrollNode const& node, Gfx::FloatPoint delta)
|
|
{
|
|
if (delta.x() < 0 && node.scroll_offset.x() > 0)
|
|
return true;
|
|
if (delta.x() > 0 && node.scroll_offset.x() < node.max_scroll_offset.x())
|
|
return true;
|
|
if (delta.y() < 0 && node.scroll_offset.y() > 0)
|
|
return true;
|
|
if (delta.y() > 0 && node.scroll_offset.y() < node.max_scroll_offset.y())
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
static bool has_scroll_node_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position, Gfx::FloatPoint delta)
|
|
{
|
|
if (!display_list)
|
|
return false;
|
|
|
|
auto const& visual_context_tree = display_list->visual_context_tree();
|
|
for (auto const& node : async_scrolling_state.scroll_nodes) {
|
|
if (!scroll_node_can_scroll_by_delta(node, delta))
|
|
continue;
|
|
|
|
auto scrollport_rect = node.is_viewport
|
|
? Gfx::FloatRect { {}, async_scrolling_state.viewport_rect.size().to_type<float>() }
|
|
: visual_context_tree.transform_rect_to_viewport(node.hit_test_visual_context_index, node.scrollport_rect.to_type<float>(), scroll_state_snapshot);
|
|
if (scrollport_rect.contains(position))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
WheelScrollAdmission admit_wheel_scroll(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position, Gfx::FloatPoint delta, bool has_blocking_wheel_event_listeners, bool blocking_wheel_event_regions_are_current)
|
|
{
|
|
if (requires_main_thread_wheel_event_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position))
|
|
return WheelScrollAdmission::BlockedByMainThreadRegion;
|
|
|
|
// Async scrolling may only start when the snapshot can prove that the wheel event cannot be canceled by script
|
|
// at this position. Stale or missing blocker information sends the event back to the main thread.
|
|
if (has_blocking_wheel_event_listeners) {
|
|
if (!blocking_wheel_event_regions_are_current)
|
|
return WheelScrollAdmission::StaleBlockingWheelEventRegions;
|
|
if (blocks_wheel_event_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position))
|
|
return WheelScrollAdmission::BlockedByWheelEventRegion;
|
|
}
|
|
|
|
if (!has_scroll_node_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position, delta))
|
|
return WheelScrollAdmission::NoScrollableTarget;
|
|
return WheelScrollAdmission::Accepted;
|
|
}
|
|
|
|
}
|