LibWeb: Prevent hit testing from transforming position more than once

The transform of each paintable was being applied multiple times due to
the recursive nature of the hit testing methods. Previously it used
combined_css_transform to transform the position, and then it would pass
that position to children, which would then apply combined_css_transform
again, and so on.

PaintableBoxes are also not hit tested anymore when having a stacking
context. A similar check is done in PaintableWithLines, but it was
missing from PaintableBox. Without this check some elements can get
returned multiple times from a hit test.

StackingContexts with zero opacity will now also get hit tested, as it
should have been before.
This commit is contained in:
zac 2025-07-28 06:23:53 +10:00 committed by Jelle Raaijmakers
parent 2569ef0f40
commit 4070f5a7e0
Notes: github-actions[bot] 2025-08-27 07:15:40 +00:00
5 changed files with 210 additions and 33 deletions

View file

@ -384,13 +384,14 @@ void StackingContext::paint(DisplayListRecordingContext& context) const
TraversalDecision StackingContext::hit_test(CSSPixelPoint position, HitTestType type, Function<TraversalDecision(HitTestResult)> const& callback) const
{
if (!paintable_box().is_visible())
if (!paintable_box().visible_for_hit_testing())
return TraversalDecision::Continue;
CSSPixelPoint transform_origin = paintable_box().transform_origin();
auto const inverse_transform = affine_transform_matrix().inverse().value_or({});
auto const transform_origin = paintable_box().transform_origin();
// NOTE: This CSSPixels -> Float -> CSSPixels conversion is because we can't AffineTransform::map() a CSSPixelPoint.
auto offset_position = position.translated(-transform_origin).to_type<float>();
auto transformed_position = affine_transform_matrix().inverse().value_or({}).map(offset_position).to_type<CSSPixels>() + transform_origin;
auto const offset_position = position.translated(-transform_origin).to_type<float>();
auto const transformed_position = inverse_transform.map(offset_position).to_type<CSSPixels>() + transform_origin;
// NOTE: Hit testing basically happens in reverse painting order.
// https://www.w3.org/TR/CSS22/visuren.html#z-index
@ -406,29 +407,29 @@ TraversalDecision StackingContext::hit_test(CSSPixelPoint position, HitTestType
}
// 6. the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
for (auto const& paintable : m_positioned_descendants_and_stacking_contexts_with_stack_level_0.in_reverse()) {
if (paintable->stacking_context()) {
if (paintable->stacking_context()->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
for (auto const& paintable_box : m_positioned_descendants_and_stacking_contexts_with_stack_level_0.in_reverse()) {
if (paintable_box->stacking_context()) {
if (paintable_box->stacking_context()->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
} else {
if (paintable->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
if (paintable_box->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
}
}
// 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
if (paintable_box().layout_node().children_are_inline() && is<Layout::BlockContainer>(paintable_box().layout_node())) {
for (auto const* child = paintable_box().last_child(); child; child = child->previous_sibling()) {
if (child->is_inline() && !child->is_absolutely_positioned() && !child->has_stacking_context()) {
if (child->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
for (auto const* paintable = paintable_box().last_child(); paintable; paintable = paintable->previous_sibling()) {
if (paintable->is_inline() && !paintable->is_absolutely_positioned() && !paintable->has_stacking_context()) {
if (paintable->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
}
}
}
// 4. the non-positioned floats.
for (auto const& paintable : m_non_positioned_floating_descendants.in_reverse()) {
if (paintable->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
for (auto const& paintable_box : m_non_positioned_floating_descendants.in_reverse()) {
if (paintable_box->hit_test(transformed_position, type, callback) == TraversalDecision::Break)
return TraversalDecision::Break;
}
@ -456,13 +457,15 @@ TraversalDecision StackingContext::hit_test(CSSPixelPoint position, HitTestType
return TraversalDecision::Break;
}
CSSPixelPoint enclosing_scroll_offset = paintable_box().cumulative_offset_of_enclosing_scroll_frame();
auto position_adjusted_by_scroll_offset = transformed_position.translated(-enclosing_scroll_offset);
auto const enclosing_scroll_offset = paintable_box().cumulative_offset_of_enclosing_scroll_frame();
auto const raw_position_adjusted_by_scroll_offset = position.translated(-enclosing_scroll_offset);
// NOTE: This CSSPixels -> Float -> CSSPixels conversion is because we can't AffineTransform::map() a CSSPixelPoint.
auto const offset_position_adjusted_by_scroll_offset = raw_position_adjusted_by_scroll_offset.translated(-transform_origin).to_type<float>();
auto const transformed_position_adjusted_by_scroll_offset = inverse_transform.map(offset_position_adjusted_by_scroll_offset).to_type<CSSPixels>() + transform_origin;
// 1. the background and borders of the element forming the stacking context.
if (paintable_box().visible_for_hit_testing()
&& paintable_box().absolute_border_box_rect().contains(position_adjusted_by_scroll_offset)) {
&& paintable_box().absolute_border_box_rect().contains(transformed_position_adjusted_by_scroll_offset)) {
if (callback({ const_cast<PaintableBox&>(paintable_box()) }) == TraversalDecision::Break)
return TraversalDecision::Break;
}