LibWeb: Implement user-select

This implements all values of user-select.
This commit is contained in:
Psychpsyo 2025-01-08 01:51:29 +01:00 committed by Sam Atkins
parent ba43dfe49a
commit 9370990ff2
Notes: github-actions[bot] 2025-01-08 14:38:35 +00:00
11 changed files with 274 additions and 29 deletions

View file

@ -1434,6 +1434,12 @@ Optional<CSS::WritingMode> ComputedProperties::writing_mode() const
return keyword_to_writing_mode(value.to_keyword());
}
Optional<CSS::UserSelect> ComputedProperties::user_select() const
{
auto const& value = property(CSS::PropertyID::UserSelect);
return keyword_to_user_select(value.to_keyword());
}
Optional<CSS::MaskType> ComputedProperties::mask_type() const
{
auto const& value = property(CSS::PropertyID::MaskType);

View file

@ -159,6 +159,7 @@ public:
Optional<CSS::Direction> direction() const;
Optional<CSS::UnicodeBidi> unicode_bidi() const;
Optional<CSS::WritingMode> writing_mode() const;
Optional<CSS::UserSelect> user_select() const;
static Vector<CSS::Transformation> transformations_for_style_value(CSSStyleValue const& value);
Vector<CSS::Transformation> transformations() const;

View file

@ -170,6 +170,7 @@ public:
static CSS::Direction direction() { return CSS::Direction::Ltr; }
static CSS::UnicodeBidi unicode_bidi() { return CSS::UnicodeBidi::Normal; }
static CSS::WritingMode writing_mode() { return CSS::WritingMode::HorizontalTb; }
static CSS::UserSelect user_select() { return CSS::UserSelect::Auto; }
// https://www.w3.org/TR/SVG/geometry.html
static LengthPercentage cx() { return CSS::Length::make_px(0); }
@ -424,6 +425,7 @@ public:
CSS::Direction direction() const { return m_inherited.direction; }
CSS::UnicodeBidi unicode_bidi() const { return m_noninherited.unicode_bidi; }
CSS::WritingMode writing_mode() const { return m_inherited.writing_mode; }
CSS::UserSelect user_select() const { return m_noninherited.user_select; }
CSS::LengthBox const& inset() const { return m_noninherited.inset; }
const CSS::LengthBox& margin() const { return m_noninherited.margin; }
@ -675,6 +677,8 @@ protected:
CSS::ObjectFit object_fit { InitialValues::object_fit() };
CSS::ObjectPosition object_position { InitialValues::object_position() };
CSS::UnicodeBidi unicode_bidi { InitialValues::unicode_bidi() };
CSS::UserSelect user_select { InitialValues::user_select() };
Optional<CSS::Transformation> rotate;
Optional<CSS::Transformation> translate;
Optional<CSS::Transformation> scale;
@ -846,6 +850,7 @@ public:
void set_direction(CSS::Direction value) { m_inherited.direction = value; }
void set_unicode_bidi(CSS::UnicodeBidi value) { m_noninherited.unicode_bidi = value; }
void set_writing_mode(CSS::WritingMode value) { m_inherited.writing_mode = value; }
void set_user_select(CSS::UserSelect value) { m_noninherited.user_select = value; }
void set_fill(SVGPaint value) { m_inherited.fill = move(value); }
void set_stroke(SVGPaint value) { m_inherited.stroke = move(value); }

View file

@ -538,6 +538,13 @@
"normal",
"plaintext"
],
"user-select": [
"all",
"auto",
"contain",
"none",
"text"
],
"vertical-align": [
"baseline",
"bottom",

View file

@ -2825,12 +2825,8 @@
"animation-type": "discrete",
"inherited": false,
"initial": "auto",
"valid-identifiers": [
"all",
"auto",
"contain",
"none",
"text"
"valid-types": [
"user-select"
]
},
"vertical-align": {

View file

@ -22,6 +22,7 @@
#include <LibWeb/CSS/StyleValues/URLStyleValue.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/Dump.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/HTML/HTMLHtmlElement.h>
#include <LibWeb/Layout/BlockContainer.h>
#include <LibWeb/Layout/FormattingContext.h>
@ -1004,6 +1005,9 @@ void NodeWithStyle::apply_style(const CSS::ComputedProperties& computed_style)
if (auto writing_mode = computed_style.writing_mode(); writing_mode.has_value())
computed_values.set_writing_mode(writing_mode.value());
if (auto user_select = computed_style.user_select(); user_select.has_value())
computed_values.set_user_select(user_select.value());
propagate_style_to_anonymous_wrappers();
}
@ -1214,4 +1218,53 @@ DOM::Document const& Node::document() const
return m_dom_node->document();
}
// https://drafts.csswg.org/css-ui/#propdef-user-select
CSS::UserSelect Node::user_select_used_value() const
{
// The used value is the same as the computed value, except:
auto computed_value = computed_values().user_select();
// 1. on editable elements where the used value is always 'contain' regardless of the computed value
// 2. when the computed value is 'auto', in which case the used value is one of the other values as defined below
// For the purpose of this specification, an editable element is either an editing host or a mutable form control with
// textual content, such as textarea.
auto* form_control = dynamic_cast<HTML::FormAssociatedTextControlElement const*>(dom_node());
// FIXME: Check if this needs to exclude input elements with types such as color or range, and if so, which ones exactly.
if ((dom_node() && dom_node()->is_editing_host()) || (form_control && form_control->is_mutable())) {
return CSS::UserSelect::Contain;
} else if (computed_value == CSS::UserSelect::Auto) {
// The used value of 'auto' is determined as follows:
// - On the '::before' and '::after' pseudo-elements, the used value is 'none'
if (is_generated_for_before_pseudo_element() || is_generated_for_after_pseudo_element()) {
return CSS::UserSelect::None;
}
// - If the element is an editable element, the used value is 'contain'
// NOTE: We already handled this above.
auto parent_element = parent();
if (parent_element) {
auto parent_used_value = parent_element->user_select_used_value();
// - Otherwise, if the used value of user-select on the parent of this element is 'all', the used value is 'all'
if (parent_used_value == CSS::UserSelect::All) {
return CSS::UserSelect::All;
}
// - Otherwise, if the used value of user-select on the parent of this element is 'none', the used value is
// 'none'
if (parent_used_value == CSS::UserSelect::None) {
return CSS::UserSelect::None;
}
}
// - Otherwise, the used value is 'text'
return CSS::UserSelect::Text;
}
return computed_value;
}
}

View file

@ -188,6 +188,9 @@ public:
return false;
}
// https://drafts.csswg.org/css-ui/#propdef-user-select
CSS::UserSelect user_select_used_value() const;
protected:
Node(DOM::Document&, DOM::Node*);

View file

@ -187,6 +187,139 @@ static CSSPixelPoint compute_mouse_event_offset(CSSPixelPoint position, Painting
return offset;
}
// https://drafts.csswg.org/css-ui/#propdef-user-select
static void set_user_selection(GC::Ptr<DOM::Node> anchor_node, unsigned anchor_offset, GC::Ptr<DOM::Node> focus_node, unsigned focus_offset, Selection::Selection* selection, CSS::UserSelect user_select)
{
// https://drafts.csswg.org/css-ui/#valdef-user-select-contain
// NOTE: 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) {
// UAs must not allow a selection which is started in this element to be extended outside of this element.
auto potential_contain_node = anchor_node;
// NOTE: The way we do this is searching up the tree from the anchor, to find 'this element', i.e. its nearest contain ancestor.
// We stop the search early when we reach an element that contains both the anchor and the focus node, as this means they
// are inside the same contain element, or not in a contain element at all.
// This takes care of the "selection trying to escape from a contain" case.
while (
(!potential_contain_node->is_element() || potential_contain_node->layout_node()->user_select_used_value() != CSS::UserSelect::Contain) && potential_contain_node->parent() && !potential_contain_node->is_inclusive_ancestor_of(*focus_node)) {
potential_contain_node = potential_contain_node->parent();
}
if (
potential_contain_node->layout_node()->user_select_used_value() == CSS::UserSelect::Contain && !potential_contain_node->is_inclusive_ancestor_of(*focus_node)) {
if (focus_node->is_before(*potential_contain_node)) {
focus_offset = 0;
} else {
focus_offset = potential_contain_node->length();
}
focus_node = potential_contain_node;
// NOTE: Prevents this from being handled again further down
user_select = CSS::UserSelect::Contain;
} else {
// A selection started outside of this element must not end in this element. If the user attempts to create such a
// selection, the UA must instead end the selection range at the element boundary.
// NOTE: This branch takes care of the "selection trying to intrude into a contain" case.
// This is done by searching up the tree from the focus node, to see if there is a
// contain element between it and the common ancestor that also includes the anchor.
// We stop once reaching target_node, which is the common ancestor identified in step 1.
// If target_node wasn't a common ancestor, we would not be here.
auto target_node = potential_contain_node;
potential_contain_node = focus_node;
while (
(!potential_contain_node->is_element() || potential_contain_node->layout_node()->user_select_used_value() != CSS::UserSelect::Contain) && potential_contain_node->parent() && potential_contain_node != target_node) {
potential_contain_node = potential_contain_node->parent();
}
if (
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;
} 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();
}
// NOTE: Prevents this from being handled again further down
user_select = CSS::UserSelect::Contain;
}
}
}
switch (user_select) {
case CSS::UserSelect::None:
// https://drafts.csswg.org/css-ui/#valdef-user-select-none
// The UA must not allow selections to be started in this element.
if (anchor_node == focus_node) {
return;
}
// A selection started outside of this element must not end in this element. If the user attempts to create such a
// selection, the UA must instead end the selection range at the element boundary.
while (focus_node->parent() && focus_node->parent()->layout_node()->user_select_used_value() == CSS::UserSelect::None) {
focus_node = focus_node->parent();
}
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;
} else {
focus_node = focus_node->previous_in_pre_order();
focus_offset = focus_node->length();
}
break;
case CSS::UserSelect::All:
// https://drafts.csswg.org/css-ui/#valdef-user-select-all
// The content of the element must be selected atomically: If a selection would contain part of the element,
// then the selection must contain the entire element including all its descendants. If the element is selected
// and the used value of 'user-select' on its parent is 'all', then the parent must be included in the selection,
// recursively.
while (focus_node->parent() && focus_node->parent()->layout_node()->user_select_used_value() == CSS::UserSelect::All) {
if (anchor_node == focus_node) {
anchor_node = focus_node->parent();
}
focus_node = focus_node->parent();
}
if (focus_node == anchor_node) {
if (anchor_offset > focus_offset) {
anchor_offset = focus_node->length();
focus_offset = 0;
} else {
anchor_offset = 0;
focus_offset = focus_node->length();
}
} else if (focus_node->is_before(*anchor_node)) {
focus_offset = 0;
} else {
focus_offset = focus_node->length();
}
break;
case CSS::UserSelect::Contain:
// NOTE: This is handled at the start of this function
break;
case CSS::UserSelect::Text:
// https://drafts.csswg.org/css-ui/#valdef-user-select-text
// The element imposes no constraint on the selection.
break;
case CSS::UserSelect::Auto:
VERIFY_NOT_REACHED();
break;
}
(void)selection->set_base_and_extent(*anchor_node, anchor_offset, *focus_node, focus_offset);
}
EventHandler::EventHandler(Badge<HTML::Navigable>, HTML::Navigable& navigable)
: m_navigable(navigable)
, m_drag_and_drop_event_handler(make<DragAndDropEventHandler>())
@ -502,23 +635,29 @@ EventResult EventHandler::handle_mousedown(CSSPixelPoint viewport_position, CSSP
else if (auto* focused_element = document->focused_element())
HTML::run_unfocusing_steps(focused_element);
auto target = document->active_input_events_target();
if (target) {
m_in_mouse_selection = true;
m_mouse_selection_target = target;
if (modifiers & UIEvents::KeyModifier::Mod_Shift) {
target->set_selection_focus(*dom_node, result->index_in_node);
} else {
target->set_selection_anchor(*dom_node, result->index_in_node);
}
} else if (!focus_candidate) {
m_in_mouse_selection = true;
if (auto selection = document->get_selection()) {
auto anchor_node = selection->anchor_node();
if (anchor_node && modifiers & UIEvents::KeyModifier::Mod_Shift) {
(void)selection->set_base_and_extent(*anchor_node, selection->anchor_offset(), *dom_node, result->index_in_node);
// https://drafts.csswg.org/css-ui/#valdef-user-select-none
// Attempting to start a selection in an element where user-select is none, such as by clicking in it or starting
// a drag in it, must not cause a pre-existing selection to become unselected or to be affected in any way.
auto user_select = paintable->layout_node().user_select_used_value();
if (user_select != CSS::UserSelect::None) {
auto target = document->active_input_events_target();
if (target) {
m_in_mouse_selection = true;
m_mouse_selection_target = target;
if (modifiers & UIEvents::KeyModifier::Mod_Shift) {
target->set_selection_focus(*dom_node, result->index_in_node);
} else {
(void)selection->set_base_and_extent(*dom_node, result->index_in_node, *dom_node, result->index_in_node);
target->set_selection_anchor(*dom_node, result->index_in_node);
}
} else if (!focus_candidate) {
m_in_mouse_selection = true;
if (auto selection = document->get_selection()) {
auto anchor_node = selection->anchor_node();
if (anchor_node && modifiers & UIEvents::KeyModifier::Mod_Shift) {
set_user_selection(*anchor_node, selection->anchor_offset(), *dom_node, result->index_in_node, selection, user_select);
} else {
set_user_selection(*dom_node, result->index_in_node, *dom_node, result->index_in_node, selection, user_select);
}
}
}
}
@ -635,9 +774,9 @@ EventResult EventHandler::handle_mousemove(CSSPixelPoint viewport_position, CSSP
auto anchor_node = selection->anchor_node();
if (anchor_node) {
if (&anchor_node->root() == &hit->dom_node()->root())
(void)selection->set_base_and_extent(*anchor_node, selection->anchor_offset(), *hit->paintable->dom_node(), hit->index_in_node);
set_user_selection(*anchor_node, selection->anchor_offset(), *hit->paintable->dom_node(), hit->index_in_node, selection, hit->paintable->layout_node().user_select_used_value());
} else {
(void)selection->set_base_and_extent(*hit->paintable->dom_node(), hit->index_in_node, *hit->paintable->dom_node(), hit->index_in_node);
set_user_selection(*hit->paintable->dom_node(), hit->index_in_node, *hit->paintable->dom_node(), hit->index_in_node, selection, hit->paintable->layout_node().user_select_used_value());
}
}
@ -751,7 +890,7 @@ EventResult EventHandler::handle_doubleclick(CSSPixelPoint viewport_position, CS
target->set_selection_anchor(hit_dom_node, previous_boundary);
target->set_selection_focus(hit_dom_node, next_boundary);
} else if (auto selection = node->document().get_selection()) {
(void)selection->set_base_and_extent(hit_dom_node, previous_boundary, hit_dom_node, next_boundary);
set_user_selection(hit_dom_node, previous_boundary, hit_dom_node, next_boundary, selection, hit_paintable.layout_node().user_select_used_value());
}
}
}

View file

@ -315,14 +315,18 @@ void ViewportPaintable::recompute_selection_states(DOM::Range& range)
// 4. Mark the selection end node as End (if text) or Full (if anything else).
if (auto* paintable = end_container->paintable()) {
if (is<DOM::Text>(*end_container))
if (is<DOM::Text>(*end_container) || end_container->is_ancestor_of(start_container)) {
paintable->set_selection_state(SelectionState::End);
else
paintable->set_selection_state(SelectionState::Full);
} else {
paintable->for_each_in_inclusive_subtree([&](auto& layout_node) {
layout_node.set_selection_state(SelectionState::Full);
return TraversalDecision::Continue;
});
}
}
// 5. Mark the nodes between start node and end node (in tree order) as Full.
for (auto* node = start_container->next_in_pre_order(); node && node != end_container; node = node->next_in_pre_order()) {
for (auto* node = start_container->next_in_pre_order(); node && (node->is_before(end_container) || node->is_descendant_of(end_container)); node = node->next_in_pre_order()) {
if (auto* paintable = node->paintable())
paintable->set_selection_state(SelectionState::Full);
}

View file

@ -0,0 +1,10 @@
Harness status: OK
Found 5 tests
5 Pass
Pass e.style['user-select'] = "auto" should set the property value
Pass e.style['user-select'] = "text" should set the property value
Pass e.style['user-select'] = "none" should set the property value
Pass e.style['user-select'] = "contain" should set the property value
Pass e.style['user-select'] = "all" should set the property value

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>CSS UI Level 4: parsing user-select with valid values</title>
<link rel="help" href="https://drafts.csswg.org/css-ui-4/#content-selection">
<meta name="assert" content="user-select supports the full grammar 'auto | text | none | contain | all'.">
<script src="../../../resources/testharness.js"></script>
<script src="../../../resources/testharnessreport.js"></script>
<script src="../../../css/support/parsing-testcommon.js"></script>
</head>
<body>
<script>
test_valid_value("user-select", "auto");
test_valid_value("user-select", "text");
test_valid_value("user-select", "none");
test_valid_value("user-select", "contain");
test_valid_value("user-select", "all");
</script>
</body>
</html>