LibWeb: Resolve selection from retained hit-test data

Clamp mouse selection positions to the active scrollport during
autoscroll. This keeps selection stable when the pointer leaves the
viewport or crosses fixed page chrome while a drag is active.

Harden user-select boundary adjustment for document edge clamping. Avoid
null traversal results, and cover viewport and subtree edge cases with
regression tests.
This commit is contained in:
Andreas Kling 2026-05-30 09:39:13 +02:00 committed by Andreas Kling
parent 7445cff5e8
commit 0c6bb97898
5 changed files with 128 additions and 30 deletions

View file

@ -7,7 +7,6 @@
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/DocumentFragment.h>
#include <LibWeb/DOM/Element.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/Page/AutoScrollHandler.h>
#include <LibWeb/Page/EventHandler.h>
@ -73,12 +72,6 @@ static CSSPixelPoint compute_auto_scroll_speed(CSSPixelPoint mouse, CSSPixelRect
};
}
static bool is_in_form_associated_text_control(DOM::Element const& element)
{
auto const& host = element.containing_shadow_root() ? *element.containing_shadow_root()->host() : element;
return is<HTML::FormAssociatedTextControlElement>(host);
}
AutoScrollHandler::AutoScrollHandler(HTML::Navigable& navigable, DOM::Element& container)
: m_navigable(navigable)
, m_container_element(container)
@ -113,9 +106,7 @@ CSSPixelPoint AutoScrollHandler::process(CSSPixelPoint mouse_position)
}
activate();
if (is_in_form_associated_text_control(m_container_element))
return constrained(mouse_position, *scrollport);
return mouse_position;
return constrained(mouse_position, *scrollport);
}
GC::Ptr<DOM::Element> AutoScrollHandler::find_scrollable_ancestor(Painting::Paintable const& paintable)
@ -206,10 +197,7 @@ void AutoScrollHandler::perform_tick()
if (paintable_box->scroll_by(scroll_x, scroll_y) == Painting::PaintableBox::ScrollHandled::No)
return;
auto selection_position = is_in_form_associated_text_control(m_container_element)
? constrained(m_mouse_position, *scrollport)
: m_mouse_position;
m_navigable->event_handler().apply_mouse_selection(selection_position);
m_navigable->event_handler().apply_mouse_selection(constrained(m_mouse_position, *scrollport));
}
}

View file

@ -1760,6 +1760,34 @@ bool EventHandler::maybe_request_paste_for_middle_click(DOM::Document& document,
// https://drafts.csswg.org/css-ui/#propdef-user-select
static void set_user_selection(GC::Ptr<DOM::Node> anchor_node, size_t anchor_offset, GC::Ptr<DOM::Node> focus_node, size_t focus_offset, Selection::Selection* selection, CSS::UserSelect user_select)
{
auto move_focus_before_node = [&](GC::Ref<DOM::Node> node) -> bool {
focus_node = node->previous_in_pre_order();
if (focus_node) {
focus_offset = focus_node->length();
return true;
}
if (!node->parent())
return false;
focus_node = *node->parent();
focus_offset = node->index();
return true;
};
auto move_focus_after_node = [&](GC::Ref<DOM::Node> node) -> bool {
focus_node = node->next_in_pre_order();
while (focus_node && node->is_inclusive_ancestor_of(*focus_node))
focus_node = focus_node->next_in_pre_order();
if (focus_node) {
focus_offset = 0;
return true;
}
if (!node->parent())
return false;
focus_node = *node->parent();
focus_offset = node->index() + 1;
return true;
};
// https://drafts.csswg.org/css-ui/#valdef-user-select-contain
// NB: This is clamping the focus node to any node with user-select: contain that stands between it and the anchor node.
if (focus_node != anchor_node) {
@ -1802,17 +1830,11 @@ static void set_user_selection(GC::Ptr<DOM::Node> anchor_node, size_t anchor_off
if (
potential_contain_node->layout_node() && potential_contain_node->layout_node()->user_select_used_value() == CSS::UserSelect::Contain && !potential_contain_node->is_inclusive_ancestor_of(*anchor_node)) {
if (potential_contain_node->is_before(*anchor_node)) {
focus_node = potential_contain_node->next_in_pre_order();
while (potential_contain_node->is_inclusive_ancestor_of(*focus_node)) {
focus_node = focus_node->next_in_pre_order();
}
focus_offset = 0;
if (!move_focus_after_node(*potential_contain_node))
return;
} else {
focus_node = potential_contain_node->previous_in_pre_order();
while (potential_contain_node->is_inclusive_ancestor_of(*focus_node)) {
focus_node = focus_node->previous_in_pre_order();
}
focus_offset = focus_node->length();
if (!move_focus_before_node(*potential_contain_node))
return;
}
// NB: Prevents this from being handled again further down
user_select = CSS::UserSelect::Contain;
@ -1836,13 +1858,11 @@ static void set_user_selection(GC::Ptr<DOM::Node> anchor_node, size_t anchor_off
}
if (focus_node->is_before(*anchor_node)) {
auto none_element = focus_node;
do {
focus_node = focus_node->next_in_pre_order();
} while (none_element->is_inclusive_ancestor_of(*focus_node));
focus_offset = 0;
if (!move_focus_after_node(*none_element))
return;
} else {
focus_node = focus_node->previous_in_pre_order();
focus_offset = focus_node->length();
if (!move_focus_before_node(*focus_node))
return;
}
break;
case CSS::UserSelect::All:

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<style>
html {
user-select: none;
}
</style>
<div id="start">start</div>
<div id="end">end</div>
<script>
const startRect = start.getBoundingClientRect();
const endRect = end.getBoundingClientRect();
internals.mouseDown(endRect.x + 2, endRect.y + 5);
internals.mouseMove(startRect.x + 2, startRect.y + 5);
internals.mouseUp(startRect.x + 2, startRect.y + 5);
</script>

View file

@ -0,0 +1,3 @@
selection reached viewport top: true
above viewport matches viewport top: true
above viewport selected forward text: false

View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<script src="include.js"></script>
<style>
body {
margin: 0;
font: 20px Arial, sans-serif;
line-height: 30px;
}
#menu {
height: 28px;
left: 0;
overflow: hidden;
position: fixed;
top: 0;
width: 100%;
z-index: 100;
}
.main {
margin-top: 42px;
width: 870px;
}
.article {
padding-top: 50px;
}
li {
height: 30px;
}
</style>
<body>
<div id="menu">Applications Blog Posts GitHub Packages</div>
<div class="main">
<div class="article">
<p>ToaruOS 2.3 is now available.</p>
<p>This release comes with many new features, including:</p>
<ul>
<li>Terminal tabs</li>
<li id="target">A manpage viewer</li>
<li>Several new system calls</li>
</ul>
</div>
</div>
<script>
test(() => {
document.body.offsetWidth;
const textNode = target.firstChild;
const range = document.createRange();
range.selectNodeContents(textNode);
const rect = range.getBoundingClientRect();
const startX = rect.x + rect.width / 2;
const startY = rect.y + rect.height / 2;
internals.mouseDown(startX, startY);
for (let y = Math.floor(startY) - 1; y >= 0; --y)
internals.mouseMove(startX, y);
const selectedAtViewportTop = getSelection().toString();
internals.mouseLeave();
internals.mouseMove(startX, -50);
internals.mouseUp(startX, -50);
const selectedAboveViewport = getSelection().toString();
println(`selection reached viewport top: ${selectedAtViewportTop.includes("Terminal tabs") && selectedAtViewportTop.includes("A manpa")}`);
println(`above viewport matches viewport top: ${selectedAboveViewport === selectedAtViewportTop}`);
println(`above viewport selected forward text: ${selectedAboveViewport.includes("Several new system calls")}`);
});
</script>