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:
parent
a11c281dc3
commit
4e047ae97b
13 changed files with 191 additions and 31 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
visualViewport: 533.333x400.000
|
||||
rootBounds: 800.000x600.000
|
||||
rootBounds match layout viewport: PASS
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue