LibWeb: Implement pinch-to-zoom support

Adds pinch event handling that adjusts the VisualViewport scale and
offset. VisualViewport's (offset, scale) is then used to construct a
transformation matrix which is applied before display list execution.
This commit is contained in:
Aliaksandr Kalenik 2025-10-09 17:30:12 +02:00 committed by Alexander Kalenik
parent b477c6bfc4
commit 9862d8b4a6
Notes: github-actions[bot] 2025-10-10 13:39:26 +00:00
14 changed files with 149 additions and 40 deletions

View file

@ -145,4 +145,41 @@ WebIDL::CallbackType* VisualViewport::onscrollend()
return event_handler_attribute(HTML::EventNames::scrollend);
}
Gfx::AffineTransform VisualViewport::transform() const
{
Gfx::AffineTransform transform;
auto offset = m_offset.to_type<double>() * m_scale;
transform.translate(-offset.x(), -offset.y());
transform.scale({ m_scale, m_scale });
return transform;
}
void VisualViewport::zoom(CSSPixelPoint position, double scale_delta)
{
static constexpr double MIN_ALLOWED_SCALE = 1.0;
static constexpr double MAX_ALLOWED_SCALE = 5.0;
double new_scale = clamp(m_scale * (1 + scale_delta), MIN_ALLOWED_SCALE, MAX_ALLOWED_SCALE);
double applied_delta = new_scale / m_scale;
// For pinch zoom we want focal_point to stay put on screen:
// scale_new * (focal_point - offset_new) = scale_old * (focal_point - offset_old)
auto new_offset = m_offset.to_type<double>() * m_scale * applied_delta;
new_offset += position.to_type<int>().to_type<double>() * (applied_delta - 1.0f);
auto viewport_float_size = m_document->navigable()->viewport_rect().size().to_type<double>();
auto max_x_offset = max(0.0, viewport_float_size.width() * (new_scale - 1.0f));
auto max_y_offset = max(0.0, viewport_float_size.height() * (new_scale - 1.0f));
new_offset = { clamp(new_offset.x(), 0.0f, max_x_offset), clamp(new_offset.y(), 0.0f, max_y_offset) };
m_scale = new_scale;
m_offset = (new_offset / m_scale).to_type<CSSPixels>();
m_document->set_needs_display(InvalidateDisplayList::No);
}
CSSPixelPoint VisualViewport::map_to_layout_viewport(CSSPixelPoint position) const
{
auto inverse = transform().inverse().value_or({});
return inverse.map(position.to_type<int>()).to_type<CSSPixels>();
}
}

View file

@ -44,6 +44,10 @@ public:
void scroll_by(CSSPixelPoint delta) { m_offset += delta; }
[[nodiscard]] Gfx::AffineTransform transform() const;
void zoom(CSSPixelPoint position, double scale_delta);
CSSPixelPoint map_to_layout_viewport(CSSPixelPoint) const;
private:
explicit VisualViewport(DOM::Document&);

View file

@ -6478,7 +6478,17 @@ RefPtr<Painting::DisplayList> Document::cached_display_list() const
RefPtr<Painting::DisplayList> Document::record_display_list(HTML::PaintConfig config)
{
auto update_visual_viewport_transform = [&](Painting::DisplayList& display_list) {
auto transform = visual_viewport()->transform();
auto matrix = transform.to_matrix();
matrix[0, 3] *= display_list.device_pixels_per_css_pixel();
matrix[1, 3] *= display_list.device_pixels_per_css_pixel();
matrix[2, 3] *= display_list.device_pixels_per_css_pixel();
display_list.set_visual_viewport_transform(matrix);
};
if (m_cached_display_list && m_cached_display_list_paint_config == config) {
update_visual_viewport_transform(*m_cached_display_list);
return m_cached_display_list;
}
@ -6542,6 +6552,7 @@ RefPtr<Painting::DisplayList> Document::record_display_list(HTML::PaintConfig co
m_cached_display_list = display_list;
m_cached_display_list_paint_config = config;
update_visual_viewport_transform(*m_cached_display_list);
return display_list;
}

View file

@ -291,8 +291,8 @@ void EventLoop::process_input_events() const
[&](Web::DragEvent& drag_event) {
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&) {
return EventResult::Dropped;
[&](Web::PinchEvent& pinch_event) {
return page.handle_pinch_event(pinch_event.position, pinch_event.scale_delta);
});
for (size_t i = 0; i < event.coalesced_event_count; ++i)

View file

@ -9,6 +9,7 @@
#include <LibUnicode/CharacterTypes.h>
#include <LibUnicode/Segmenter.h>
#include <LibWeb/CSS/VisualViewport.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/Editing/Internal/Algorithms.h>
#include <LibWeb/HTML/CloseWatcherManager.h>
@ -383,17 +384,20 @@ GC::Ptr<Painting::PaintableBox const> EventHandler::paint_root() const
return m_navigable->active_document()->paintable_box();
}
EventResult EventHandler::handle_mousewheel(CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers, int wheel_delta_x, int wheel_delta_y)
EventResult EventHandler::handle_mousewheel(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers, int wheel_delta_x, int wheel_delta_y)
{
if (should_ignore_device_input_event())
return EventResult::Dropped;
if (!m_navigable->active_document())
auto document = m_navigable->active_document();
if (!document)
return EventResult::Dropped;
if (!m_navigable->active_document()->is_fully_active())
if (!document->is_fully_active())
return EventResult::Dropped;
m_navigable->active_document()->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseWheel);
auto viewport_position = document->visual_viewport()->map_to_layout_viewport(visual_viewport_position);
document->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseWheel);
if (!paint_root())
return EventResult::Dropped;
@ -448,16 +452,19 @@ EventResult EventHandler::handle_mousewheel(CSSPixelPoint viewport_position, CSS
return handled_event;
}
EventResult EventHandler::handle_mouseup(CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
EventResult EventHandler::handle_mouseup(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
{
if (should_ignore_device_input_event())
return EventResult::Dropped;
if (!m_navigable->active_document())
auto document = m_navigable->active_document();
if (!document)
return EventResult::Dropped;
if (!m_navigable->active_document()->is_fully_active())
if (!document->is_fully_active())
return EventResult::Dropped;
auto viewport_position = document->visual_viewport()->map_to_layout_viewport(visual_viewport_position);
m_navigable->active_document()->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseUp);
if (!paint_root())
@ -594,22 +601,24 @@ after_node_use:
return handled_event;
}
EventResult EventHandler::handle_mousedown(CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
EventResult EventHandler::handle_mousedown(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
{
if (should_ignore_device_input_event())
return EventResult::Dropped;
if (!m_navigable->active_document())
auto document = m_navigable->active_document();
if (!document)
return EventResult::Dropped;
if (!m_navigable->active_document()->is_fully_active())
if (!document->is_fully_active())
return EventResult::Dropped;
auto viewport_position = document->visual_viewport()->map_to_layout_viewport(visual_viewport_position);
m_navigable->active_document()->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseDown);
if (!paint_root())
return EventResult::Dropped;
GC::Ref<DOM::Document> document = *m_navigable->active_document();
GC::Ptr<DOM::Node> node;
ScopeGuard update_hovered_node_guard = [&node, &document] {
@ -723,7 +732,7 @@ EventResult EventHandler::handle_mousedown(CSSPixelPoint viewport_position, CSSP
return EventResult::Handled;
}
EventResult EventHandler::handle_mousemove(CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 buttons, u32 modifiers)
EventResult EventHandler::handle_mousemove(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 buttons, u32 modifiers)
{
if (should_ignore_device_input_event())
return EventResult::Dropped;
@ -733,13 +742,14 @@ EventResult EventHandler::handle_mousemove(CSSPixelPoint viewport_position, CSSP
if (!m_navigable->active_document()->is_fully_active())
return EventResult::Dropped;
auto& document = *m_navigable->active_document();
auto viewport_position = document.visual_viewport()->map_to_layout_viewport(visual_viewport_position);
m_navigable->active_document()->update_layout(DOM::UpdateLayoutReason::EventHandlerHandleMouseMove);
if (!paint_root())
return EventResult::Dropped;
auto& document = *m_navigable->active_document();
bool hovered_node_changed = false;
bool is_hovering_link = false;
Gfx::Cursor hovered_node_cursor = Gfx::StandardCursor::None;
@ -912,7 +922,7 @@ EventResult EventHandler::handle_mouseleave()
return EventResult::Handled;
}
EventResult EventHandler::handle_doubleclick(CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
EventResult EventHandler::handle_doubleclick(CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers)
{
if (should_ignore_device_input_event())
return EventResult::Dropped;
@ -923,6 +933,7 @@ EventResult EventHandler::handle_doubleclick(CSSPixelPoint viewport_position, CS
return EventResult::Dropped;
auto& document = *m_navigable->active_document();
auto viewport_position = document.visual_viewport()->map_to_layout_viewport(visual_viewport_position);
document.update_layout(DOM::UpdateLayoutReason::EventHandlerHandleDoubleClick);
@ -1004,7 +1015,7 @@ EventResult EventHandler::handle_doubleclick(CSSPixelPoint viewport_position, CS
return EventResult::Handled;
}
EventResult EventHandler::handle_drag_and_drop_event(DragEvent::Type type, CSSPixelPoint viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers, Vector<HTML::SelectedFile> files)
EventResult EventHandler::handle_drag_and_drop_event(DragEvent::Type type, CSSPixelPoint visual_viewport_position, CSSPixelPoint screen_position, u32 button, u32 buttons, u32 modifiers, Vector<HTML::SelectedFile> files)
{
if (!m_navigable->active_document())
return EventResult::Dropped;
@ -1012,6 +1023,8 @@ EventResult EventHandler::handle_drag_and_drop_event(DragEvent::Type type, CSSPi
return EventResult::Dropped;
auto& document = *m_navigable->active_document();
auto viewport_position = document.visual_viewport()->map_to_layout_viewport(visual_viewport_position);
document.update_layout(DOM::UpdateLayoutReason::EventHandlerHandleDragAndDrop);
if (!paint_root())
@ -1050,6 +1063,19 @@ EventResult EventHandler::handle_drag_and_drop_event(DragEvent::Type type, CSSPi
VERIFY_NOT_REACHED();
}
EventResult EventHandler::handle_pinch_event(CSSPixelPoint point, double scale_delta)
{
auto document = m_navigable->active_document();
if (!document)
return EventResult::Dropped;
if (!document->is_fully_active())
return EventResult::Dropped;
auto visual_viewport = document->visual_viewport();
visual_viewport->zoom(point, scale_delta);
return EventResult::Handled;
}
EventResult EventHandler::focus_next_element()
{
if (!m_navigable->active_document())

View file

@ -37,6 +37,8 @@ public:
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_keydown(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);
EventResult handle_keyup(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);

View file

@ -234,6 +234,11 @@ 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)
{
return top_level_traversable()->event_handler().handle_pinch_event(device_to_css_point(position), scale);
}
EventResult Page::handle_keydown(UIEvents::KeyCode key, unsigned modifiers, u32 code_point, bool repeat)
{
return focused_navigable().event_handler().handle_keydown(key, modifiers, code_point, repeat);

View file

@ -98,6 +98,7 @@ public:
EventResult handle_doubleclick(DevicePixelPoint, DevicePixelPoint screen_position, unsigned button, unsigned buttons, unsigned modifiers);
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_keydown(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);
EventResult handle_keyup(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat);

View file

@ -109,6 +109,9 @@ public:
}
}
static constexpr size_t VISUAL_VIEWPORT_TRANSFORM_INDEX = 1;
void set_visual_viewport_transform(Gfx::FloatMatrix4x4 t) { m_commands[VISUAL_VIEWPORT_TRANSFORM_INDEX].command.get<ApplyTransform>().matrix = t; }
private:
DisplayList(double device_pixels_per_css_pixel)
: m_device_pixels_per_css_pixel(device_pixels_per_css_pixel)
@ -117,6 +120,7 @@ private:
AK::SegmentedVector<DisplayListCommandWithScrollAndClip, 512> m_commands;
double m_device_pixels_per_css_pixel;
Optional<Gfx::FloatMatrix4x4> m_visual_viewport_transform;
};
}

View file

@ -24,9 +24,16 @@ StackingContextTransform::StackingContextTransform(Gfx::FloatPoint origin, Gfx::
DisplayListRecorder::DisplayListRecorder(DisplayList& command_list)
: m_display_list(command_list)
{
save();
// Reserve for visual viewport transform
VERIFY(m_display_list.commands().size() == DisplayList::VISUAL_VIEWPORT_TRANSFORM_INDEX);
apply_transform({}, Gfx::FloatMatrix4x4::identity());
}
DisplayListRecorder::~DisplayListRecorder() = default;
DisplayListRecorder::~DisplayListRecorder()
{
restore();
}
template<typename T>
consteval static int command_nesting_level_change(T const& command)

View file

@ -1,3 +1,5 @@
Save
ApplyTransform matrix=[1 0 0 1 0 0]
SaveLayer
DrawGlyphRun rect=[35,8 8x18] translation=[35.15625,21.796875] color=rgb(0, 0, 0) scale=1
DrawGlyphRun rect=[8,8 28x18] translation=[8,21.796875] color=rgb(0, 0, 0) scale=1
@ -5,4 +7,5 @@ SaveLayer
DrawGlyphRun rect=[43,8 28x18] translation=[43.15625,21.796875] color=rgb(0, 0, 0) scale=1
DrawLine from=[43,27] to=[71,27] color=rgb(0, 0, 0) thickness=2
Restore
Restore

View file

@ -1,7 +1,10 @@
Save
ApplyTransform matrix=[1 0 0 1 0 0]
SaveLayer
FillRect rect=[8,8 106x22] color=rgb(212, 208, 200)
FillPath
DrawGlyphRun rect=[13,10 96x18] translation=[13,23.796875] color=rgb(0, 0, 0) scale=1
DrawLine from=[13,26] to=[109,26] color=rgb(0, 0, 0) thickness=2
Restore
Restore

View file

@ -1,3 +1,5 @@
Save
ApplyTransform matrix=[1 0 0 1 0 0]
SaveLayer
FillRect rect=[8,8 29x30] color=rgb(0, 0, 0)
FillRect rect=[38,8 28x30] color=rgb(0, 0, 0)
@ -5,4 +7,5 @@ SaveLayer
FillRect rect=[97,8 28x30] color=rgb(0, 0, 0)
FillRect rect=[126,8 29x30] color=rgb(0, 0, 0)
Restore
Restore

View file

@ -1,6 +1,9 @@
Save
ApplyTransform matrix=[1 0 0 1 0 0]
SaveLayer
FillPath
FillRect rect=[10,10 300x150] color=rgb(240, 128, 128)
DrawGlyphRun rect=[10,10 38x18] translation=[10,23.796875] color=rgb(0, 0, 0) scale=1
Restore
Restore