LibWeb: Chain wheel scrolling past iframe limits

Let wheel scrolling over iframes fall through to the parent document
when the child document cannot make progress. This covers both iframes
with no scrollable viewport and iframes that are already at their
scroll boundary.

Treat nested wheel events as terminal only when the child document
actually consumes them or cancels them. For iframe viewport scrolling,
check whether the child viewport position changed before reporting the
wheel event as handled.
This commit is contained in:
Andreas Kling 2026-04-17 11:43:32 +02:00 committed by Andreas Kling
parent dfeb1e6b98
commit 8233200bc3
5 changed files with 109 additions and 5 deletions

View file

@ -610,8 +610,10 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_positi
if (auto result = dispatch_event_to_nested_navigable(*paintable, visual_viewport_position, [screen_position, button, buttons, modifiers, wheel_delta_x, wheel_delta_y](EventHandler& event_handler, CSSPixelPoint position) -> EventResult {
return event_handler.handle_mousewheel(position, screen_position, button, buttons, modifiers, wheel_delta_x, wheel_delta_y);
});
result.has_value())
return result.value();
result.has_value()) {
if (result.value() == EventResult::Handled || result.value() == EventResult::Cancelled)
return result.value();
}
// NB: Search for the first parent of the hit target that's an element.
GC::Ptr<Layout::Node> layout_node;
@ -622,11 +624,19 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_positi
auto const& offset_paintable = layout_node->first_paintable() ? layout_node->first_paintable() : paintable.ptr();
auto scroll_offset = document->navigable()->viewport_scroll_offset();
auto offset = compute_mouse_event_offset(visual_viewport_position.translated(scroll_offset), *offset_paintable);
bool could_scroll_viewport = document->paintable_box()->could_be_scrolled_by_wheel_event();
if (node->dispatch_event(UIEvents::WheelEvent::create_from_platform_event(node->realm(), m_navigable->active_window_proxy(), UIEvents::EventNames::wheel, screen_position, page_offset, viewport_position, offset, wheel_delta_x, wheel_delta_y, button, buttons, modifiers).release_value_but_fixme_should_propagate_errors())) {
m_navigable->scroll_viewport_by_delta({ wheel_delta_x, wheel_delta_y });
if (could_scroll_viewport) {
auto viewport_scroll_position_before = CSSPixelPoint { CSSPixels(document->visual_viewport()->page_left()), CSSPixels(document->visual_viewport()->page_top()) };
m_navigable->scroll_viewport_by_delta({ wheel_delta_x, wheel_delta_y });
auto viewport_scroll_position_after = CSSPixelPoint { CSSPixels(document->visual_viewport()->page_left()), CSSPixels(document->visual_viewport()->page_top()) };
handled_event = viewport_scroll_position_before != viewport_scroll_position_after ? EventResult::Handled : EventResult::Accepted;
} else {
handled_event = EventResult::Accepted;
}
} else {
handled_event = EventResult::Cancelled;
}
handled_event = EventResult::Handled;
}
}

View file

@ -0,0 +1,5 @@
iframe.scrollTop before wheel: 450
iframe.scrollHeight: 600
iframe.clientHeight: 150
window.scrollY: 100
iframe.scrollTop after wheel: 450

View file

@ -0,0 +1,2 @@
window.scrollY: 100
iframe.scrollY: 0

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
margin: 0;
}
#iframe {
display: block;
width: 200px;
height: 150px;
border: none;
}
#spacer {
height: 2000px;
background: linear-gradient(white, orange);
}
</style>
<div id="spacer"></div>
<script>
promiseTest(async () => {
const iframe = document.createElement("iframe");
iframe.id = "iframe";
iframe.srcdoc = `
<!DOCTYPE html>
<style>
body { margin: 0; }
html { scrollbar-width: none; }
</style>
<div style="width: 200px; height: 200px; background-color: darkblue;"></div>
<div style="width: 200px; height: 200px; background-color: blue;"></div>
<div style="width: 200px; height: 200px; background-color: magenta;"></div>
`;
document.body.prepend(iframe);
await new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
const scrollingElement = iframe.contentDocument.scrollingElement;
scrollingElement.scrollTop = 10000;
await animationFrame();
println(`iframe.scrollTop before wheel: ${scrollingElement.scrollTop}`);
println(`iframe.scrollHeight: ${scrollingElement.scrollHeight}`);
println(`iframe.clientHeight: ${scrollingElement.clientHeight}`);
internals.wheel(50, 50, 0, 100);
await animationFrame();
println(`window.scrollY: ${window.scrollY}`);
println(`iframe.scrollTop after wheel: ${scrollingElement.scrollTop}`);
});
</script>

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
margin: 0;
}
iframe {
display: block;
width: 200px;
height: 150px;
border: none;
}
#spacer {
height: 2000px;
background: linear-gradient(white, orange);
}
</style>
<iframe id="iframe" srcdoc="<!DOCTYPE html><body style='margin: 0; height: 50px; background: lightblue;'>iframe</body>"></iframe>
<div id="spacer"></div>
<script>
promiseTest(async () => {
const iframe = document.getElementById("iframe");
await new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
internals.wheel(50, 50, 0, 100);
await animationFrame();
println(`window.scrollY: ${window.scrollY}`);
println(`iframe.scrollY: ${iframe.contentWindow.scrollY}`);
});
</script>