LibWeb: Keep pinch zoom out of client rects

Keep the visual viewport transform out of Element client rects and
IntersectionObserver geometry. Pinch zoom should change the visual
viewport, but not the layout viewport coordinates exposed through DOM
geometry APIs.

Thread an opt-out through rectangle mapping so paint and hit testing
still use the full visual transform while web-observable geometry can
stay in layout viewport coordinates. This matches the Blink and WebKit
page scale model and keeps responsive script from treating pinch zoom
like a relayout.

Add coverage for getBoundingClientRect() under pinch zoom and visual
viewport IntersectionObserver geometry.
This commit is contained in:
Andreas Kling 2026-06-14 21:31:10 +02:00 committed by Andreas Kling
parent a11c281dc3
commit 4e047ae97b
13 changed files with 191 additions and 31 deletions

View file

@ -6151,7 +6151,7 @@ static CSSPixelRect compute_intersection(GC::Ref<Element> target, CSSPixelRect t
auto overflow_y = container->computed_values().overflow_y();
bool has_content_clip = overflow_x != CSS::Overflow::Visible || overflow_y != CSS::Overflow::Visible;
if (has_content_clip) {
auto clip_rect = container->transform_rect_to_viewport(container->absolute_padding_box_rect());
auto clip_rect = container->transform_rect_to_viewport(container->absolute_padding_box_rect(), Painting::AccumulatedVisualContextTree::IncludeVisualViewportTransform::No);
// Apply scroll margin to expand the scrollport for scroll containers.
auto& scroll_margin = observer.scroll_margin_values();

View file

@ -1639,7 +1639,7 @@ static Vector<CSSPixelRect> compute_client_rects_assuming_layout_clean(Element c
Vector<CSSPixelRect> rects;
if (auto paintable_box = element.paintable_box()) {
auto absolute_rect = paintable_box->absolute_border_box_rect();
rects.append(paintable_box->transform_rect_to_viewport(absolute_rect));
rects.append(paintable_box->transform_rect_to_viewport(absolute_rect, Painting::AccumulatedVisualContextTree::IncludeVisualViewportTransform::No));
} else if (element.paintable()) {
dbgln("FIXME: Failed to get client rects for element ({})", element.debug_description());
}

View file

@ -285,9 +285,12 @@ CSSPixelRect IntersectionObserver::root_intersection_rectangle() const
// Since the spec says that this is only reach if the document is fully active, that means it must have a navigable.
VERIFY(document->navigable());
// NOTE: This rect is the *size* of the viewport. The viewport *offset* is not relevant,
// as intersections are computed using viewport-relative element rects.
rect = CSSPixelRect { CSSPixelPoint { 0, 0 }, document->viewport_rect().size() };
// NOTE: This rect is in the same layout viewport coordinate space as
// Element::getBoundingClientRect().
rect = CSSPixelRect {
CSSPixelPoint { 0, 0 },
document->viewport_rect().size(),
};
} else {
VERIFY(intersection_root.has<GC::Ref<DOM::Element>>());
auto element = intersection_root.get<GC::Ref<DOM::Element>>();

View file

@ -607,32 +607,34 @@ Gfx::FloatPoint AccumulatedVisualContextTree::inverse_transform_point(VisualCont
return point;
}
Gfx::FloatRect AccumulatedVisualContextTree::transform_rect_to_viewport(VisualContextIndex index, Gfx::FloatRect const& source_rect, ScrollStateSnapshot const& scroll_state) const
Gfx::FloatRect AccumulatedVisualContextTree::transform_rect_to_viewport(VisualContextIndex index, Gfx::FloatRect const& source_rect, ScrollStateSnapshot const& scroll_state, IncludeVisualViewportTransform include_visual_viewport_transform) const
{
auto rect = source_rect;
for (size_t i = index.value();; i = m_nodes[i].parent_index.value()) {
auto const& node = m_nodes[i];
node.data.visit(
[&](TransformData const& transform) {
auto affine = Gfx::extract_2d_affine_transform(transform.matrix);
rect.translate_by(-transform.origin);
rect = affine.map(rect);
rect.translate_by(transform.origin);
},
[&](PerspectiveData const& perspective) {
auto affine = Gfx::extract_2d_affine_transform(perspective.matrix);
rect = affine.map(rect);
},
[&](ScrollData const& scroll) {
rect.translate_by(scroll_state.device_offset_for_index(scroll.scroll_frame_index));
},
[&](ScrollCompensation const& compensation) {
auto offset = scroll_state.device_offset_for_index(compensation.scroll_frame_index);
rect.translate_by(-offset);
},
[&](ClipData const&) { /* clips don't affect rect coordinates */ },
[&](ClipPathData const&) { /* clip paths don't affect rect coordinates */ },
[&](EffectsData const&) { /* effects don't affect rect coordinates */ });
if (i != VISUAL_VIEWPORT_NODE_INDEX.value() || include_visual_viewport_transform == IncludeVisualViewportTransform::Yes) {
node.data.visit(
[&](TransformData const& transform) {
auto affine = Gfx::extract_2d_affine_transform(transform.matrix);
rect.translate_by(-transform.origin);
rect = affine.map(rect);
rect.translate_by(transform.origin);
},
[&](PerspectiveData const& perspective) {
auto affine = Gfx::extract_2d_affine_transform(perspective.matrix);
rect = affine.map(rect);
},
[&](ScrollData const& scroll) {
rect.translate_by(scroll_state.device_offset_for_index(scroll.scroll_frame_index));
},
[&](ScrollCompensation const& compensation) {
auto offset = scroll_state.device_offset_for_index(compensation.scroll_frame_index);
rect.translate_by(-offset);
},
[&](ClipData const&) { /* clips don't affect rect coordinates */ },
[&](ClipPathData const&) { /* clip paths don't affect rect coordinates */ },
[&](EffectsData const&) { /* effects don't affect rect coordinates */ });
}
if (i == VISUAL_VIEWPORT_NODE_INDEX.value())
break;
}

View file

@ -93,6 +93,11 @@ struct AccumulatedVisualContextNode {
class AccumulatedVisualContextTree {
public:
enum class IncludeVisualViewportTransform {
No,
Yes,
};
static AccumulatedVisualContextTree create();
static AccumulatedVisualContextTree create(TransformData visual_viewport_transform);
@ -113,7 +118,7 @@ public:
VisualContextIndex find_common_ancestor(VisualContextIndex a, VisualContextIndex b) const;
Optional<Gfx::FloatPoint> transform_point_for_hit_test(VisualContextIndex, Gfx::FloatPoint, ScrollStateSnapshot const&) const;
Gfx::FloatPoint inverse_transform_point(VisualContextIndex, Gfx::FloatPoint) const;
Gfx::FloatRect transform_rect_to_viewport(VisualContextIndex, Gfx::FloatRect const&, ScrollStateSnapshot const&) const;
Gfx::FloatRect transform_rect_to_viewport(VisualContextIndex, Gfx::FloatRect const&, ScrollStateSnapshot const&, IncludeVisualViewportTransform = IncludeVisualViewportTransform::Yes) const;
void dump(VisualContextIndex, StringBuilder&) const;
bool has_empty_effective_clip(VisualContextIndex i) const { return m_nodes[i.value()].has_empty_effective_clip; }

View file

@ -1768,7 +1768,7 @@ Optional<CSSPixelPoint> PaintableBox::transform_point_to_local_for_descendants(C
return (*result / pixel_ratio).to_type<CSSPixels>();
}
CSSPixelRect PaintableBox::transform_rect_to_viewport(CSSPixelRect const& rect) const
CSSPixelRect PaintableBox::transform_rect_to_viewport(CSSPixelRect const& rect, AccumulatedVisualContextTree::IncludeVisualViewportTransform include_visual_viewport_transform) const
{
auto viewport_paintable = document().paintable();
if (!viewport_paintable || !viewport_paintable->has_visual_context_tree())
@ -1776,7 +1776,7 @@ CSSPixelRect PaintableBox::transform_rect_to_viewport(CSSPixelRect const& rect)
auto pixel_ratio = static_cast<float>(document().page().client().device_pixels_per_css_pixel());
auto const& scroll_state = viewport_paintable->scroll_state_snapshot();
auto const& visual_context_tree = viewport_paintable->visual_context_tree();
auto result = visual_context_tree.transform_rect_to_viewport(m_accumulated_visual_context_index, rect.to_type<float>() * pixel_ratio, scroll_state);
auto result = visual_context_tree.transform_rect_to_viewport(m_accumulated_visual_context_index, rect.to_type<float>() * pixel_ratio, scroll_state, include_visual_viewport_transform);
return (result * (1.f / pixel_ratio)).to_type<CSSPixels>();
}

View file

@ -300,7 +300,7 @@ public:
Optional<CSSPixelPoint> transform_point_to_local(CSSPixelPoint screen_position) const;
Optional<CSSPixelPoint> transform_point_to_local_for_descendants(CSSPixelPoint screen_position) const;
CSSPixelRect transform_rect_to_viewport(CSSPixelRect const& rect) const;
CSSPixelRect transform_rect_to_viewport(CSSPixelRect const& rect, AccumulatedVisualContextTree::IncludeVisualViewportTransform = AccumulatedVisualContextTree::IncludeVisualViewportTransform::Yes) const;
CSSPixelPoint inverse_transform_point(CSSPixelPoint screen_position) const;
static constexpr size_t paint_phase_count = to_underlying(PaintPhase::Overlay) + 1;

View file

@ -0,0 +1,4 @@
before: 0.000,0.000 100.000x100.000
visualViewport scale: 1.500
after: 0.000,0.000 100.000x100.000
size unchanged: PASS

View file

@ -0,0 +1,11 @@
visualViewport: 533.333x400.000 scale=1.500
visualViewport offset: 33.328,33.328
top-left target origin: 0.000,0.000
top-left target size: 100.000x100.000
top-left rootBounds origin: 0.000,0.000
top-left rootBounds size: 800.000x600.000
top-left intersectionRect origin: 0.000,0.000
top-left intersectionRect size: 100.000x100.000
top-left intersectionRatio: 1.000
right target isIntersecting: PASS
right target intersectionRatio: 1.000

View file

@ -0,0 +1,3 @@
visualViewport: 533.333x400.000
rootBounds: 800.000x600.000
rootBounds match layout viewport: PASS

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<style>
body {
margin: 0;
}
#target {
width: 100px;
height: 100px;
}
</style>
<div id="target"></div>
<script src="include.js"></script>
<script>
asyncTest(async done => {
await animationFrame();
const target = document.getElementById("target");
const before = target.getBoundingClientRect();
println(`before: ${before.x.toFixed(3)},${before.y.toFixed(3)} ${before.width.toFixed(3)}x${before.height.toFixed(3)}`);
await new Promise(resolve => {
visualViewport.addEventListener("resize", resolve, { once: true });
internals.pinch(100, 100, 0.5);
});
const after = target.getBoundingClientRect();
println(`visualViewport scale: ${visualViewport.scale.toFixed(3)}`);
println(`after: ${after.x.toFixed(3)},${after.y.toFixed(3)} ${after.width.toFixed(3)}x${after.height.toFixed(3)}`);
println(`size unchanged: ${after.width === before.width && after.height === before.height ? "PASS" : "FAIL"}`);
done();
});
</script>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<style>
body {
margin: 0;
}
#top-left-target,
#right-target {
width: 100px;
height: 100px;
}
#right-target {
margin-left: 400px;
}
</style>
<div id="top-left-target"></div>
<div id="right-target"></div>
<script src="include.js"></script>
<script>
function observe(target) {
return new Promise(resolve => {
const observer = new IntersectionObserver(entries => {
observer.disconnect();
resolve(entries[0]);
});
observer.observe(target);
});
}
asyncTest(async done => {
function fixed(value) {
return value.toFixed(3);
}
await animationFrame();
await new Promise(resolve => {
visualViewport.addEventListener("resize", resolve, { once: true });
internals.pinch(100, 100, 0.5);
});
const topLeftTarget = document.getElementById("top-left-target");
const topLeftRect = topLeftTarget.getBoundingClientRect();
println(`visualViewport: ${fixed(visualViewport.width)}x${fixed(visualViewport.height)} scale=${fixed(visualViewport.scale)}`);
println(`visualViewport offset: ${fixed(visualViewport.offsetLeft)},${fixed(visualViewport.offsetTop)}`);
println(`top-left target origin: ${fixed(topLeftRect.x)},${fixed(topLeftRect.y)}`);
println(`top-left target size: ${fixed(topLeftRect.width)}x${fixed(topLeftRect.height)}`);
const topLeftEntry = await observe(topLeftTarget);
println(`top-left rootBounds origin: ${fixed(topLeftEntry.rootBounds.x)},${fixed(topLeftEntry.rootBounds.y)}`);
println(`top-left rootBounds size: ${fixed(topLeftEntry.rootBounds.width)}x${fixed(topLeftEntry.rootBounds.height)}`);
println(`top-left intersectionRect origin: ${fixed(topLeftEntry.intersectionRect.x)},${fixed(topLeftEntry.intersectionRect.y)}`);
println(`top-left intersectionRect size: ${fixed(topLeftEntry.intersectionRect.width)}x${fixed(topLeftEntry.intersectionRect.height)}`);
println(`top-left intersectionRatio: ${fixed(topLeftEntry.intersectionRatio)}`);
const rightEntry = await observe(document.getElementById("right-target"));
println(`right target isIntersecting: ${rightEntry.isIntersecting ? "PASS" : "FAIL"}`);
println(`right target intersectionRatio: ${fixed(rightEntry.intersectionRatio)}`);
done();
});
</script>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<style>
body {
margin: 0;
}
#target {
width: 100px;
height: 100px;
}
</style>
<div id="target"></div>
<script src="include.js"></script>
<script>
asyncTest(async done => {
await animationFrame();
await new Promise(resolve => {
visualViewport.addEventListener("resize", resolve, { once: true });
internals.pinch(100, 100, 0.5);
});
const observer = new IntersectionObserver(entries => {
const rootBounds = entries[0].rootBounds;
const expectedWidth = innerWidth;
const expectedHeight = innerHeight;
const rootBoundsMatch = Math.abs(rootBounds.width - expectedWidth) < 0.01
&& Math.abs(rootBounds.height - expectedHeight) < 0.01;
println(`visualViewport: ${visualViewport.width.toFixed(3)}x${visualViewport.height.toFixed(3)}`);
println(`rootBounds: ${rootBounds.width.toFixed(3)}x${rootBounds.height.toFixed(3)}`);
println(`rootBounds match layout viewport: ${rootBoundsMatch ? "PASS" : "FAIL"}`);
observer.disconnect();
done();
});
observer.observe(document.getElementById("target"));
});
</script>