LibWeb+UI: Synthesize ctrl-wheel events for pinches

Trackpad pinches applied directly as visual viewport zoom, so pages
could not observe the gesture or cancel the browser default. Canvas
apps such as maps expect the ctrl-wheel path instead.
This commit is contained in:
Aliaksandr Kalenik 2026-05-27 04:04:15 +02:00 committed by Jelle Raaijmakers
parent baecb14472
commit 9ab311507e
15 changed files with 173 additions and 30 deletions

View file

@ -274,7 +274,7 @@ void EventLoop::process_input_events() const
return page.handle_drag_and_drop_event(drag_event.type, drag_event.position, drag_event.screen_position, drag_event.button, drag_event.buttons, drag_event.modifiers, move(drag_event.files));
},
[&](Web::PinchEvent& pinch_event) {
return page.handle_pinch_event(pinch_event.position, pinch_event.scale_delta);
return page.handle_pinch_event(pinch_event.position, pinch_event.modifiers, pinch_event.scale_delta);
});
for (size_t i = 0; i < event.coalesced_event_count; ++i)

View file

@ -388,11 +388,11 @@ GC::Ref<WebIDL::Promise> Internals::wheel(double x, double y, double delta_x, do
return promise;
}
void Internals::pinch(double x, double y, double scale_delta)
void Internals::pinch(double x, double y, double scale_delta, WebIDL::UnsignedShort modifiers)
{
auto& page = this->page();
auto position = page.css_to_device_point({ x, y });
page.handle_pinch_event(position, scale_delta);
page.handle_pinch_event(position, modifiers, scale_delta);
}
String Internals::current_cursor()

View file

@ -66,7 +66,7 @@ public:
void click(double x, double y, WebIDL::UnsignedShort click_count, WebIDL::UnsignedShort button, WebIDL::UnsignedShort modifiers);
void click_and_hold(double x, double y, WebIDL::UnsignedShort click_count, WebIDL::UnsignedShort button, WebIDL::UnsignedShort modifiers);
GC::Ref<WebIDL::Promise> wheel(double x, double y, double delta_x, double delta_y);
void pinch(double x, double y, double scale_delta);
void pinch(double x, double y, double scale_delta, WebIDL::UnsignedShort modifiers);
String current_cursor();

View file

@ -41,7 +41,7 @@ interface Internals {
undefined click(double x, double y, optional unsigned short clickCount = 1, optional unsigned short button = 0, optional unsigned short modifiers = 0);
undefined clickAndHold(double x, double y, optional unsigned short clickCount = 1, optional unsigned short button = 0, optional unsigned short modifiers = 0);
Promise<undefined> wheel(double x, double y, double deltaX, double deltaY);
undefined pinch(double x, double y, double scaleDelta);
undefined pinch(double x, double y, double scaleDelta, optional unsigned short modifiers = 0);
DOMString currentCursor();

View file

@ -8,6 +8,7 @@
*/
#include <AK/Debug.h>
#include <AK/Math.h>
#include <LibGfx/Bitmap.h>
#include <LibUnicode/CharacterTypes.h>
#include <LibUnicode/Segmenter.h>
@ -528,7 +529,6 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_positi
return EventResult::Dropped;
auto visual_viewport = document->visual_viewport();
auto viewport_position = visual_viewport->map_to_layout_viewport(visual_viewport_position);
document->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseWheel);
@ -632,28 +632,19 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_positi
return result.value();
}
// NB: Search for the first parent of the hit target that's an element.
GC::Ptr<Layout::Node> layout_node;
if (!parent_element_for_event_dispatch(*paintable, node, layout_node))
return EventResult::Dropped;
auto page_offset = compute_mouse_event_page_offset(viewport_position);
RefPtr<Painting::Paintable> offset_paintable = layout_node->first_paintable();
if (!offset_paintable)
offset_paintable = paintable;
auto scroll_offset = document->navigable()->viewport_scroll_offset();
auto offset = compute_mouse_event_offset(visual_viewport_position.translated(scroll_offset), *offset_paintable);
auto is_cancelable = async_scroll_performed_default_action ? UIEvents::WheelEventIsCancelable::No : UIEvents::WheelEventIsCancelable::Yes;
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, is_cancelable).release_value_but_fixme_should_propagate_errors())) {
auto dispatch_result = dispatch_wheel_event(*paintable, visual_viewport_position, screen_position, button, buttons, modifiers, wheel_delta_x, wheel_delta_y, is_cancelable == UIEvents::WheelEventIsCancelable::Yes);
if (dispatch_result == EventResult::Accepted) {
if (async_scroll_performed_default_action)
handled_event = EventResult::Handled;
else
handled_event = perform_wheel_default_action(resolve_wheel_default_action_target());
} else if (async_scroll_performed_default_action) {
} else if (dispatch_result == EventResult::Cancelled && async_scroll_performed_default_action) {
handled_event = EventResult::Handled;
} else {
} else if (dispatch_result == EventResult::Cancelled) {
handled_event = EventResult::Cancelled;
}
} else
handled_event = dispatch_result;
} else if (!async_scroll_performed_default_action) {
handled_event = perform_wheel_default_action(paintable);
}
@ -662,6 +653,66 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_positi
return handled_event;
}
EventResult EventHandler::dispatch_wheel_event(Painting::Paintable& paintable, CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers, double wheel_delta_x, double wheel_delta_y, bool is_cancelable)
{
auto document = m_navigable->active_document();
if (!document || !document->is_fully_active())
return EventResult::Dropped;
auto node = dom_node_for_event_dispatch(paintable);
if (!node)
return EventResult::Dropped;
// NB: Search for the first parent of the hit target that's an element.
GC::Ptr<Layout::Node> layout_node;
if (!parent_element_for_event_dispatch(paintable, node, layout_node))
return EventResult::Dropped;
auto viewport_position = document->visual_viewport()->map_to_layout_viewport(visual_viewport_position);
auto page_offset = compute_mouse_event_page_offset(viewport_position);
RefPtr<Painting::Paintable> offset_paintable = layout_node->first_paintable();
if (!offset_paintable)
offset_paintable = &paintable;
auto scroll_offset = document->navigable()->viewport_scroll_offset();
auto offset = compute_mouse_event_offset(visual_viewport_position.translated(scroll_offset), *offset_paintable);
auto cancelability = is_cancelable ? UIEvents::WheelEventIsCancelable::Yes : UIEvents::WheelEventIsCancelable::No;
auto 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, cancelability).release_value_but_fixme_should_propagate_errors();
return node->dispatch_event(event) ? EventResult::Accepted : EventResult::Cancelled;
}
EventResult EventHandler::dispatch_synthetic_pinch_wheel_event(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 modifiers, double wheel_delta_y)
{
auto document = m_navigable->active_document();
if (!document || !document->is_fully_active())
return EventResult::Dropped;
document->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseWheel);
if (!paint_root())
return EventResult::Dropped;
RefPtr<Painting::Paintable> paintable;
if (auto result = target_for_mouse_position(visual_viewport_position); result.has_value())
paintable = result->paintable;
if (!paintable)
return EventResult::Dropped;
if (!dom_node_for_event_dispatch(*paintable))
return EventResult::Dropped;
if (auto result = dispatch_event_to_nested_navigable(*paintable, visual_viewport_position, [screen_position, modifiers, wheel_delta_y](EventHandler& event_handler, CSSPixelPoint position) -> EventResult {
return event_handler.dispatch_synthetic_pinch_wheel_event(position, screen_position, modifiers, wheel_delta_y);
});
result.has_value()) {
if (result.value() == EventResult::Handled || result.value() == EventResult::Cancelled)
return result.value();
}
modifiers |= UIEvents::KeyModifier::Mod_Ctrl;
return dispatch_wheel_event(*paintable, visual_viewport_position, screen_position, UIEvents::MouseButton::None, UIEvents::MouseButton::None, modifiers, 0, wheel_delta_y, true);
}
EventResult EventHandler::handle_mouseleave()
{
if (should_ignore_device_input_event()) {
@ -1122,7 +1173,7 @@ bool EventHandler::should_ignore_device_input_event() const
return m_drag_and_drop_event_handler->has_ongoing_drag_and_drop_operation();
}
EventResult EventHandler::handle_pinch_event(CSSPixelPoint point, double scale_delta)
EventResult EventHandler::handle_pinch_event(CSSPixelPoint point, u32 modifiers, double scale_delta)
{
auto document = m_navigable->active_document();
if (!document)
@ -1130,8 +1181,19 @@ EventResult EventHandler::handle_pinch_event(CSSPixelPoint point, double scale_d
if (!document->is_fully_active())
return EventResult::Dropped;
auto visual_viewport = document->visual_viewport();
visual_viewport->zoom(point, scale_delta);
auto scale = 1.0 + scale_delta;
if (scale == 1.0)
return EventResult::Handled;
auto wheel_delta_y = -100.0 * AK::log(scale);
if (dispatch_synthetic_pinch_wheel_event(point, point, modifiers, wheel_delta_y) == EventResult::Cancelled)
return EventResult::Cancelled;
document = m_navigable->active_document();
if (!document || !document->is_fully_active())
return EventResult::Dropped;
document->visual_viewport()->zoom(point, scale_delta);
return EventResult::Handled;
}

View file

@ -54,7 +54,7 @@ public:
EventResult handle_keyup(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);
EventResult handle_drag_and_drop_event(DragEvent::Type, CSSPixelPoint, CSSPixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers, Vector<HTML::SelectedFile> files);
EventResult handle_pinch_event(CSSPixelPoint, double scale_delta);
EventResult handle_pinch_event(CSSPixelPoint, unsigned modifiers, double scale_delta);
EventResult handle_paste(Utf16String const& text);
void handle_sdl_input_events();
@ -123,6 +123,8 @@ private:
void stop_updating_selection();
void update_hover_after_scroll(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers);
EventResult dispatch_wheel_event(Painting::Paintable&, CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers, double wheel_delta_x, double wheel_delta_y, bool is_cancelable);
EventResult dispatch_synthetic_pinch_wheel_event(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, unsigned modifiers, double wheel_delta_y);
enum class PointerEventType : u8 {
PointerDown,

View file

@ -115,6 +115,7 @@ template<>
WEB_API ErrorOr<void> IPC::encode(Encoder& encoder, Web::PinchEvent const& event)
{
TRY(encoder.encode(event.position));
TRY(encoder.encode(event.modifiers));
TRY(encoder.encode(event.scale_delta));
return {};
}
@ -123,10 +124,11 @@ template<>
WEB_API ErrorOr<Web::PinchEvent> IPC::decode(Decoder& decoder)
{
auto position = TRY(decoder.decode<Web::DevicePixelPoint>());
auto modifiers = TRY(decoder.decode<Web::UIEvents::KeyModifier>());
auto scale_delta = TRY(decoder.decode<double>());
if (isnan(scale_delta) || isinf(scale_delta))
return Error::from_string_literal("IPC: Invalid scale_delta value");
return Web::PinchEvent { position, scale_delta };
return Web::PinchEvent { position, modifiers, scale_delta };
}

View file

@ -88,6 +88,7 @@ struct WEB_API DragEvent {
struct WEB_API PinchEvent {
Web::DevicePixelPoint position;
UIEvents::KeyModifier modifiers { UIEvents::KeyModifier::Mod_None };
double scale_delta;
};

View file

@ -334,9 +334,9 @@ EventResult Page::handle_drag_and_drop_event(DragEvent::Type type, DevicePixelPo
return top_level_traversable()->event_handler().handle_drag_and_drop_event(type, device_to_css_point(position), device_to_css_point(screen_position), button, buttons, modifiers, move(files));
}
EventResult Page::handle_pinch_event(DevicePixelPoint position, double scale)
EventResult Page::handle_pinch_event(DevicePixelPoint position, unsigned modifiers, double scale)
{
return top_level_traversable()->event_handler().handle_pinch_event(device_to_css_point(position), scale);
return top_level_traversable()->event_handler().handle_pinch_event(device_to_css_point(position), modifiers, scale);
}
EventResult Page::handle_keydown(UIEvents::KeyCode key, unsigned modifiers, u32 code_point, bool repeat)

View file

@ -121,7 +121,7 @@ public:
EventResult handle_mousewheel(DevicePixelPoint, DevicePixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers, double wheel_delta_x, double wheel_delta_y, bool async_scroll_performed_default_action = false, Optional<AsyncScrollOperation>* async_scroll_operation = nullptr);
EventResult handle_drag_and_drop_event(DragEvent::Type, DevicePixelPoint, DevicePixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers, Vector<HTML::SelectedFile> files);
EventResult handle_pinch_event(DevicePixelPoint point, double scale);
EventResult handle_pinch_event(DevicePixelPoint point, unsigned modifiers, double scale);
EventResult handle_keydown(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);
EventResult handle_keyup(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);

View file

@ -0,0 +1,15 @@
initial scale: 1.00
events after prevented pinch: 1
ctrlKey: true
shiftKey: true
cancelable: true
defaultPrevented: true
deltaX: 0
deltaY: -4.8790
deltaMode: 0
scale after prevented pinch: 1.00
events after unprevented pinch: 2
scale after unprevented pinch: 1.05
scrollY after unprevented pinch: 0
events after zero-delta pinch: 2
scale after zero-delta pinch: 1.05

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
margin: 0;
min-height: 2000px;
}
#target {
width: 200px;
height: 200px;
}
</style>
<div id="target"></div>
<script>
test(() => {
const visualViewport = window.visualViewport;
const events = [];
let shouldPreventDefault = true;
window.addEventListener("wheel", event => {
if (event.ctrlKey && shouldPreventDefault)
event.preventDefault();
events.push({
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
cancelable: event.cancelable,
defaultPrevented: event.defaultPrevented,
deltaX: event.deltaX,
deltaY: event.deltaY,
deltaMode: event.deltaMode,
});
}, { passive: false });
println(`initial scale: ${visualViewport.scale.toFixed(2)}`);
internals.pinch(100, 100, 0.05, internals.MOD_SHIFT);
println(`events after prevented pinch: ${events.length}`);
println(`ctrlKey: ${events[0].ctrlKey}`);
println(`shiftKey: ${events[0].shiftKey}`);
println(`cancelable: ${events[0].cancelable}`);
println(`defaultPrevented: ${events[0].defaultPrevented}`);
println(`deltaX: ${events[0].deltaX}`);
println(`deltaY: ${events[0].deltaY.toFixed(4)}`);
println(`deltaMode: ${events[0].deltaMode}`);
println(`scale after prevented pinch: ${visualViewport.scale.toFixed(2)}`);
shouldPreventDefault = false;
internals.pinch(100, 100, 0.05);
println(`events after unprevented pinch: ${events.length}`);
println(`scale after unprevented pinch: ${visualViewport.scale.toFixed(2)}`);
println(`scrollY after unprevented pinch: ${window.scrollY}`);
internals.pinch(100, 100, 0);
println(`events after zero-delta pinch: ${events.length}`);
println(`scale after zero-delta pinch: ${visualViewport.scale.toFixed(2)}`);
});
</script>

View file

@ -14,6 +14,7 @@
namespace Ladybird {
Web::UIEvents::KeyModifier ns_modifiers_to_key_modifiers(NSEventModifierFlags);
Web::MouseEvent ns_event_to_mouse_event(Web::MouseEvent::Type, NSEvent*, NSView*, Web::UIEvents::MouseButton);
Web::DragEvent ns_event_to_drag_event(Web::DragEvent::Type, id<NSDraggingInfo>, NSView*);

View file

@ -17,7 +17,7 @@
namespace Ladybird {
static Web::UIEvents::KeyModifier ns_modifiers_to_key_modifiers(NSEventModifierFlags modifier_flags)
Web::UIEvents::KeyModifier ns_modifiers_to_key_modifiers(NSEventModifierFlags modifier_flags)
{
unsigned modifiers = Web::UIEvents::KeyModifier::Mod_None;

View file

@ -1396,6 +1396,7 @@ struct HideCursor {
NSPoint point = [recognizer locationInView:self];
Web::PinchEvent pinch_event;
pinch_event.position = Ladybird::ns_point_to_gfx_point(point).to_type<Web::DevicePixels>() * m_web_view_bridge->device_pixel_ratio();
pinch_event.modifiers = Ladybird::ns_modifiers_to_key_modifiers([NSEvent modifierFlags]);
pinch_event.scale_delta = scale_delta;
m_web_view_bridge->enqueue_input_event(move(pinch_event));
}