Root-level blocking wheel listeners still need to disable async wheel routing globally, but non-root listeners only block the regions where their event path can cancel the wheel event. The previous routing admission treated any blocking listener as global, which prevented async scrolling in unaffected nested scrollports. Base routing admission on viewport-covering blocking regions instead of the presence of any blocking listener, leaving local blocker rejection to the compositor hit test. Update internals so blocked positions report no async wheel target. Update the admission test to cover the routing distinction and add a nested scroller test where a non-passive wheel listener calls preventDefault(), keeping both the scroller and the viewport at their original offsets.
418 lines
18 KiB
C++
418 lines
18 KiB
C++
/*
|
|
* Copyright (c) 2026-present, the Ladybird developers.
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <LibWeb/Compositor/AsyncScrollTree.h>
|
|
#include <LibWeb/Painting/DisplayList.h>
|
|
|
|
#include <AK/Debug.h>
|
|
|
|
namespace Web::Compositor {
|
|
|
|
void AsyncScrollTree::set_state(AsyncScrollingState&& state)
|
|
{
|
|
m_scroll_nodes = move(state.scroll_nodes);
|
|
m_sticky_areas = move(state.sticky_areas);
|
|
m_wheel_hit_test_regions = move(state.wheel_hit_test_targets);
|
|
m_main_thread_wheel_event_regions = move(state.main_thread_wheel_event_regions);
|
|
m_blocking_wheel_event_regions = move(state.blocking_wheel_event_regions);
|
|
m_has_blocking_wheel_event_region_covering_viewport = state.has_blocking_wheel_event_region_covering_viewport;
|
|
m_wheel_hit_test_targets.clear();
|
|
m_main_thread_wheel_event_targets.clear();
|
|
m_cached_blocking_wheel_event_targets.clear();
|
|
m_visual_context_tree = nullptr;
|
|
}
|
|
|
|
AsyncScrollNode const* AsyncScrollTree::scroll_node_for_id(AsyncScrollNodeID node_id) const
|
|
{
|
|
for (auto const& node : m_scroll_nodes) {
|
|
if (node.node_id == node_id)
|
|
return &node;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
WheelHitTestResult AsyncScrollTree::hit_test_result_for_scroll_node(AsyncScrollNodeID node_id, Gfx::FloatPoint delta) const
|
|
{
|
|
auto const* node = scroll_node_for_id(node_id);
|
|
if (!node)
|
|
return {};
|
|
if (can_scroll_node_by_delta(*node, m_scroll_state_snapshot, delta))
|
|
return { node_id, false };
|
|
if (auto ancestor = scrollable_ancestor_for_node(node_id, m_scroll_state_snapshot, delta); ancestor.has_value())
|
|
return { ancestor, false };
|
|
return {};
|
|
}
|
|
|
|
AsyncScrollNode const* AsyncScrollTree::scroll_node_for_stable_id(AsyncScrollNodeStableID stable_node_id) const
|
|
{
|
|
for (auto const& node : m_scroll_nodes) {
|
|
if (node.stable_node_id == stable_node_id)
|
|
return &node;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
AsyncStickyArea const* AsyncScrollTree::sticky_area_for_scroll_frame_index(Painting::ScrollFrameIndex scroll_frame_index) const
|
|
{
|
|
for (auto const& sticky_area : m_sticky_areas) {
|
|
if (sticky_area.scroll_frame_index == scroll_frame_index)
|
|
return &sticky_area;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
Gfx::FloatPoint AsyncScrollTree::clamp_scroll_offset_to_node(AsyncScrollNode const& node, Gfx::FloatPoint scroll_offset)
|
|
{
|
|
scroll_offset.set_x(max(0.0f, min(scroll_offset.x(), node.max_scroll_offset.x())));
|
|
scroll_offset.set_y(max(0.0f, min(scroll_offset.y(), node.max_scroll_offset.y())));
|
|
return scroll_offset;
|
|
}
|
|
|
|
Gfx::FloatPoint AsyncScrollTree::scroll_offset_for_node(AsyncScrollNode const& node, Painting::ScrollStateSnapshot const& scroll_state_snapshot)
|
|
{
|
|
auto device_offset = scroll_state_snapshot.device_offset_for_index(node.node_id.scroll_frame_index);
|
|
return { -device_offset.x(), -device_offset.y() };
|
|
}
|
|
|
|
bool AsyncScrollTree::can_scroll_node_by_delta(AsyncScrollNode const& node, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint delta)
|
|
{
|
|
auto scroll_offset = scroll_offset_for_node(node, scroll_state_snapshot);
|
|
if (delta.x() < 0 && scroll_offset.x() > 0)
|
|
return true;
|
|
if (delta.x() > 0 && scroll_offset.x() < node.max_scroll_offset.x())
|
|
return true;
|
|
if (delta.y() < 0 && scroll_offset.y() > 0)
|
|
return true;
|
|
if (delta.y() > 0 && scroll_offset.y() < node.max_scroll_offset.y())
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
bool AsyncScrollTree::has_non_zero_scroll_delta(Gfx::FloatPoint delta)
|
|
{
|
|
return delta.x() != 0 || delta.y() != 0;
|
|
}
|
|
|
|
Optional<AsyncScrollNodeID> AsyncScrollTree::scrollable_ancestor_for_node(AsyncScrollNodeID node_id, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint delta) const
|
|
{
|
|
auto const* node = scroll_node_for_id(node_id);
|
|
if (!node)
|
|
return {};
|
|
|
|
auto parent_node_id = node->parent_node_id;
|
|
while (parent_node_id.has_value()) {
|
|
auto const* parent_node = scroll_node_for_id(*parent_node_id);
|
|
if (!parent_node)
|
|
return {};
|
|
if (can_scroll_node_by_delta(*parent_node, scroll_state_snapshot, delta))
|
|
return *parent_node_id;
|
|
parent_node_id = parent_node->parent_node_id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
Optional<Painting::ScrollFrameIndex> AsyncScrollTree::parent_scroll_frame_index(Painting::ScrollFrameIndex scroll_frame_index) const
|
|
{
|
|
for (auto const& node : m_scroll_nodes) {
|
|
if (node.node_id.scroll_frame_index != scroll_frame_index)
|
|
continue;
|
|
if (!node.parent_node_id.has_value())
|
|
return {};
|
|
return node.parent_node_id->scroll_frame_index;
|
|
}
|
|
|
|
if (auto const* sticky_area = sticky_area_for_scroll_frame_index(scroll_frame_index))
|
|
return sticky_area->parent_scroll_frame_index;
|
|
|
|
return {};
|
|
}
|
|
|
|
Gfx::FloatPoint AsyncScrollTree::cumulative_device_offset_for_frame(Painting::ScrollFrameIndex scroll_frame_index, Painting::ScrollStateSnapshot const& scroll_state_snapshot) const
|
|
{
|
|
Gfx::FloatPoint offset;
|
|
for (auto index = scroll_frame_index; index.value();) {
|
|
offset.translate_by(scroll_state_snapshot.device_offset_for_index(index));
|
|
auto parent_index = parent_scroll_frame_index(index);
|
|
if (!parent_index.has_value())
|
|
break;
|
|
index = *parent_index;
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
Gfx::FloatPoint AsyncScrollTree::apply_scroll_delta_to_node(AsyncScrollNode const& node, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot& scroll_state_snapshot)
|
|
{
|
|
auto old_scroll_offset = scroll_offset_for_node(node, scroll_state_snapshot);
|
|
auto new_scroll_offset = clamp_scroll_offset_to_node(node, old_scroll_offset.translated(delta));
|
|
if (new_scroll_offset == old_scroll_offset) {
|
|
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll node {} did not move for delta {},{} (offset={},{} max={},{})",
|
|
node.node_id.scroll_frame_index.value(), delta.x(), delta.y(), old_scroll_offset.x(), old_scroll_offset.y(), node.max_scroll_offset.x(), node.max_scroll_offset.y());
|
|
return delta;
|
|
}
|
|
|
|
scroll_state_snapshot.set_device_offset_for_index(node.node_id.scroll_frame_index, { -new_scroll_offset.x(), -new_scroll_offset.y() });
|
|
|
|
Gfx::FloatPoint consumed_delta {
|
|
new_scroll_offset.x() - old_scroll_offset.x(),
|
|
new_scroll_offset.y() - old_scroll_offset.y()
|
|
};
|
|
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll node {} moved from {},{} to {},{} (consumed={},{} remaining={},{})",
|
|
node.node_id.scroll_frame_index.value(),
|
|
old_scroll_offset.x(), old_scroll_offset.y(),
|
|
new_scroll_offset.x(), new_scroll_offset.y(),
|
|
consumed_delta.x(), consumed_delta.y(),
|
|
delta.x() - consumed_delta.x(), delta.y() - consumed_delta.y());
|
|
return {
|
|
delta.x() - consumed_delta.x(),
|
|
delta.y() - consumed_delta.y()
|
|
};
|
|
}
|
|
|
|
void AsyncScrollTree::update_sticky_offsets(Painting::ScrollStateSnapshot& scroll_state_snapshot) const
|
|
{
|
|
// This mirrors ViewportPaintable::refresh_scroll_state(), but consumes the compositor's mutated
|
|
// ScrollStateSnapshot instead of live layout objects.
|
|
for (auto const& sticky_area : m_sticky_areas) {
|
|
if (!sticky_area.nearest_scrolling_ancestor_index.value())
|
|
continue;
|
|
|
|
Gfx::FloatPoint parent_sticky_offset;
|
|
if (sticky_area.parent_scroll_frame_index.value() && sticky_area_for_scroll_frame_index(sticky_area.parent_scroll_frame_index))
|
|
parent_sticky_offset = cumulative_device_offset_for_frame(sticky_area.parent_scroll_frame_index, scroll_state_snapshot);
|
|
|
|
auto sticky_position_in_ancestor = sticky_area.position_relative_to_scroll_ancestor.translated(parent_sticky_offset);
|
|
|
|
auto containing_block_region = sticky_area.containing_block_region;
|
|
if (sticky_area.needs_parent_offset_adjustment)
|
|
containing_block_region.translate_by(parent_sticky_offset);
|
|
|
|
auto min_offset_within_containing_block = containing_block_region.top_left();
|
|
Gfx::FloatPoint max_offset_within_containing_block {
|
|
containing_block_region.right() - sticky_area.border_box_size.width(),
|
|
containing_block_region.bottom() - sticky_area.border_box_size.height()
|
|
};
|
|
|
|
auto ancestor_device_offset = scroll_state_snapshot.device_offset_for_index(sticky_area.nearest_scrolling_ancestor_index);
|
|
Gfx::FloatPoint scroll_ancestor_scroll_offset { -ancestor_device_offset.x(), -ancestor_device_offset.y() };
|
|
Gfx::FloatRect scrollport_rect { scroll_ancestor_scroll_offset, sticky_area.scrollport_size };
|
|
Gfx::FloatPoint sticky_offset;
|
|
|
|
if (sticky_area.inset_top.has_value()) {
|
|
if (scrollport_rect.top() > sticky_position_in_ancestor.y() - *sticky_area.inset_top)
|
|
sticky_offset.set_y(min(scrollport_rect.top() + *sticky_area.inset_top, max_offset_within_containing_block.y()) - sticky_position_in_ancestor.y());
|
|
}
|
|
if (sticky_area.inset_left.has_value()) {
|
|
if (scrollport_rect.left() > sticky_position_in_ancestor.x() - *sticky_area.inset_left)
|
|
sticky_offset.set_x(min(scrollport_rect.left() + *sticky_area.inset_left, max_offset_within_containing_block.x()) - sticky_position_in_ancestor.x());
|
|
}
|
|
if (sticky_area.inset_bottom.has_value()) {
|
|
if (scrollport_rect.bottom() < sticky_position_in_ancestor.y() + sticky_area.border_box_size.height() + *sticky_area.inset_bottom)
|
|
sticky_offset.set_y(max(scrollport_rect.bottom() - sticky_area.border_box_size.height() - *sticky_area.inset_bottom, min_offset_within_containing_block.y()) - sticky_position_in_ancestor.y());
|
|
}
|
|
if (sticky_area.inset_right.has_value()) {
|
|
if (scrollport_rect.right() < sticky_position_in_ancestor.x() + sticky_area.border_box_size.width() + *sticky_area.inset_right)
|
|
sticky_offset.set_x(max(scrollport_rect.right() - sticky_area.border_box_size.width() - *sticky_area.inset_right, min_offset_within_containing_block.x()) - sticky_position_in_ancestor.x());
|
|
}
|
|
|
|
scroll_state_snapshot.set_device_offset_for_index(sticky_area.scroll_frame_index, sticky_offset);
|
|
}
|
|
}
|
|
|
|
static void set_or_append_scroll_offset(Vector<AsyncScrollOffset>& scroll_offsets, AsyncScrollNode const& node, Gfx::FloatPoint scroll_offset)
|
|
{
|
|
for (auto& existing : scroll_offsets) {
|
|
if (existing.stable_node_id == node.stable_node_id) {
|
|
existing.scroll_offset = scroll_offset;
|
|
return;
|
|
}
|
|
}
|
|
scroll_offsets.append({
|
|
.stable_node_id = node.stable_node_id,
|
|
.scroll_offset = scroll_offset,
|
|
});
|
|
}
|
|
|
|
Vector<AsyncScrollOffset> AsyncScrollTree::apply_scroll_delta(AsyncScrollNodeID node_id, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot& scroll_state_snapshot)
|
|
{
|
|
// The compositor can advance only the scroll offsets it owns in this snapshot. When a target reaches an edge,
|
|
// the remaining wheel delta is handed to the nearest scrollable ancestor from the same immutable tree.
|
|
Vector<AsyncScrollOffset> scroll_offsets;
|
|
auto remaining_delta = delta;
|
|
for (size_t remaining_handoffs = m_scroll_nodes.size(); remaining_handoffs > 0 && has_non_zero_scroll_delta(remaining_delta); --remaining_handoffs) {
|
|
auto const* node = scroll_node_for_id(node_id);
|
|
if (!node)
|
|
break;
|
|
|
|
auto delta_before_scroll = remaining_delta;
|
|
remaining_delta = apply_scroll_delta_to_node(*node, remaining_delta, scroll_state_snapshot);
|
|
if (remaining_delta != delta_before_scroll)
|
|
set_or_append_scroll_offset(scroll_offsets, *node, scroll_offset_for_node(*node, scroll_state_snapshot));
|
|
if (!has_non_zero_scroll_delta(remaining_delta))
|
|
break;
|
|
|
|
auto ancestor_node_id = scrollable_ancestor_for_node(node_id, scroll_state_snapshot, remaining_delta);
|
|
if (!ancestor_node_id.has_value())
|
|
break;
|
|
node_id = *ancestor_node_id;
|
|
}
|
|
|
|
if (!scroll_offsets.is_empty())
|
|
update_sticky_offsets(scroll_state_snapshot);
|
|
else
|
|
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll tree did not scroll any node for delta {},{}",
|
|
delta.x(), delta.y());
|
|
|
|
return scroll_offsets;
|
|
}
|
|
|
|
void AsyncScrollTree::rebuild_wheel_hit_test_targets(RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot)
|
|
{
|
|
m_wheel_hit_test_targets.clear();
|
|
m_main_thread_wheel_event_targets.clear();
|
|
m_cached_blocking_wheel_event_targets.clear();
|
|
m_visual_context_tree = nullptr;
|
|
m_scroll_state_snapshot = scroll_state_snapshot;
|
|
if (!display_list)
|
|
return;
|
|
|
|
auto const& visual_context_tree = display_list->visual_context_tree();
|
|
m_visual_context_tree = &visual_context_tree;
|
|
|
|
m_wheel_hit_test_targets.ensure_capacity(m_wheel_hit_test_regions.size());
|
|
for (auto const& target : m_wheel_hit_test_regions) {
|
|
m_wheel_hit_test_targets.append({
|
|
.target_node_id = target.target_node_id,
|
|
.visual_context_index = target.visual_context_index,
|
|
.rect = target.rect,
|
|
.corner_radii = target.corner_radii,
|
|
.viewport_rect = visual_context_tree.transform_rect_to_viewport(target.visual_context_index, target.rect, scroll_state_snapshot),
|
|
});
|
|
}
|
|
|
|
m_main_thread_wheel_event_targets.ensure_capacity(m_main_thread_wheel_event_regions.size());
|
|
for (auto const& region : m_main_thread_wheel_event_regions) {
|
|
m_main_thread_wheel_event_targets.append({
|
|
.visual_context_index = region.visual_context_index,
|
|
.rect = region.rect,
|
|
.viewport_rect = visual_context_tree.transform_rect_to_viewport(region.visual_context_index, region.rect, scroll_state_snapshot),
|
|
});
|
|
}
|
|
|
|
for (auto const& region : m_blocking_wheel_event_regions) {
|
|
m_cached_blocking_wheel_event_targets.append({
|
|
.visual_context_index = region.visual_context_index,
|
|
.rect = region.rect,
|
|
.viewport_rect = visual_context_tree.transform_rect_to_viewport(region.visual_context_index, region.rect, scroll_state_snapshot),
|
|
});
|
|
}
|
|
}
|
|
|
|
void AsyncScrollTree::clear_wheel_hit_test_targets()
|
|
{
|
|
m_wheel_hit_test_targets.clear();
|
|
m_main_thread_wheel_event_targets.clear();
|
|
m_cached_blocking_wheel_event_targets.clear();
|
|
m_visual_context_tree = nullptr;
|
|
}
|
|
|
|
static bool wheel_hit_test_target_contains_point(CachedWheelHitTestTarget const& target, Gfx::FloatPoint position_in_context)
|
|
{
|
|
if (!target.rect.contains(position_in_context))
|
|
return false;
|
|
if (!target.corner_radii.has_any_radius())
|
|
return true;
|
|
return target.corner_radii.contains(position_in_context.to_type<int>(), target.rect.to_type<int>());
|
|
}
|
|
|
|
Optional<Gfx::FloatPoint> AsyncScrollTree::scroll_offset_for_node(AsyncScrollNodeID node_id, Painting::ScrollStateSnapshot const& scroll_state_snapshot) const
|
|
{
|
|
if (auto const* node = scroll_node_for_id(node_id))
|
|
return scroll_offset_for_node(*node, scroll_state_snapshot);
|
|
return {};
|
|
}
|
|
|
|
Optional<AsyncScrollNodeID> AsyncScrollTree::viewport_scroll_node_id() const
|
|
{
|
|
for (auto const& node : m_scroll_nodes) {
|
|
if (node.is_viewport)
|
|
return node.node_id;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
Optional<AsyncScrollNodeID> AsyncScrollTree::scroll_node_id_for_stable_id(AsyncScrollNodeStableID stable_node_id) const
|
|
{
|
|
if (auto const* node = scroll_node_for_stable_id(stable_node_id))
|
|
return node->node_id;
|
|
return {};
|
|
}
|
|
|
|
WheelHitTestResult AsyncScrollTree::hit_test_scroll_node_for_wheel(Gfx::FloatPoint position, Gfx::FloatPoint delta) const
|
|
{
|
|
if (!m_visual_context_tree)
|
|
return {};
|
|
|
|
if (m_has_blocking_wheel_event_region_covering_viewport)
|
|
return { {}, false, true };
|
|
|
|
for (auto const& target : m_main_thread_wheel_event_targets) {
|
|
if (!target.viewport_rect.contains(position))
|
|
continue;
|
|
|
|
auto position_in_context = m_visual_context_tree->transform_point_for_hit_test(target.visual_context_index, position, m_scroll_state_snapshot);
|
|
if (position_in_context.has_value() && target.rect.contains(*position_in_context))
|
|
return { {}, true };
|
|
}
|
|
|
|
for (auto const& target : m_cached_blocking_wheel_event_targets) {
|
|
if (!target.viewport_rect.contains(position))
|
|
continue;
|
|
|
|
auto position_in_context = m_visual_context_tree->transform_point_for_hit_test(target.visual_context_index, position, m_scroll_state_snapshot);
|
|
if (position_in_context.has_value() && target.rect.contains(*position_in_context))
|
|
return { {}, false, true };
|
|
}
|
|
|
|
for (auto const& target : m_wheel_hit_test_targets.in_reverse()) {
|
|
if (!target.viewport_rect.contains(position))
|
|
continue;
|
|
|
|
auto position_in_context = m_visual_context_tree->transform_point_for_hit_test(target.visual_context_index, position, m_scroll_state_snapshot);
|
|
if (!position_in_context.has_value() || !wheel_hit_test_target_contains_point(target, *position_in_context))
|
|
continue;
|
|
if (!target.target_node_id.has_value())
|
|
return {};
|
|
return hit_test_result_for_scroll_node(*target.target_node_id, delta);
|
|
}
|
|
|
|
auto viewport_node_id = viewport_scroll_node_id();
|
|
if (!viewport_node_id.has_value())
|
|
return {};
|
|
auto const* viewport_node = scroll_node_for_id(*viewport_node_id);
|
|
if (!viewport_node || !viewport_node->scrollport_rect.to_type<float>().contains(position))
|
|
return {};
|
|
return hit_test_result_for_scroll_node(*viewport_node_id, delta);
|
|
}
|
|
|
|
bool AsyncScrollTree::scroll_node_is_viewport(AsyncScrollNodeID node_id) const
|
|
{
|
|
auto const* node = scroll_node_for_id(node_id);
|
|
return node && node->is_viewport;
|
|
}
|
|
|
|
Optional<Gfx::FloatPoint> AsyncScrollTree::set_scroll_offset(AsyncScrollNodeID node_id, Gfx::FloatPoint scroll_offset, Painting::ScrollStateSnapshot& scroll_state_snapshot)
|
|
{
|
|
auto const* node = scroll_node_for_id(node_id);
|
|
if (!node)
|
|
return {};
|
|
|
|
auto new_scroll_offset = clamp_scroll_offset_to_node(*node, scroll_offset);
|
|
scroll_state_snapshot.set_device_offset_for_index(node->node_id.scroll_frame_index, { -new_scroll_offset.x(), -new_scroll_offset.y() });
|
|
update_sticky_offsets(scroll_state_snapshot);
|
|
return new_scroll_offset;
|
|
}
|
|
|
|
}
|