diff --git a/Libraries/LibWeb/CSS/ComputedProperties.cpp b/Libraries/LibWeb/CSS/ComputedProperties.cpp index 3a471c5bd55..6b8b40552cc 100644 --- a/Libraries/LibWeb/CSS/ComputedProperties.cpp +++ b/Libraries/LibWeb/CSS/ComputedProperties.cpp @@ -1434,6 +1434,12 @@ Optional ComputedProperties::writing_mode() const return keyword_to_writing_mode(value.to_keyword()); } +Optional ComputedProperties::user_select() const +{ + auto const& value = property(CSS::PropertyID::UserSelect); + return keyword_to_user_select(value.to_keyword()); +} + Optional ComputedProperties::mask_type() const { auto const& value = property(CSS::PropertyID::MaskType); diff --git a/Libraries/LibWeb/CSS/ComputedProperties.h b/Libraries/LibWeb/CSS/ComputedProperties.h index 2ebbdab8809..9e7d8bf6a5c 100644 --- a/Libraries/LibWeb/CSS/ComputedProperties.h +++ b/Libraries/LibWeb/CSS/ComputedProperties.h @@ -159,6 +159,7 @@ public: Optional direction() const; Optional unicode_bidi() const; Optional writing_mode() const; + Optional user_select() const; static Vector transformations_for_style_value(CSSStyleValue const& value); Vector transformations() const; diff --git a/Libraries/LibWeb/CSS/ComputedValues.h b/Libraries/LibWeb/CSS/ComputedValues.h index 2a65c027834..38f0d67d29a 100644 --- a/Libraries/LibWeb/CSS/ComputedValues.h +++ b/Libraries/LibWeb/CSS/ComputedValues.h @@ -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 rotate; Optional translate; Optional 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); } diff --git a/Libraries/LibWeb/CSS/Enums.json b/Libraries/LibWeb/CSS/Enums.json index 6bc9d84bed0..b0c44b4658b 100644 --- a/Libraries/LibWeb/CSS/Enums.json +++ b/Libraries/LibWeb/CSS/Enums.json @@ -538,6 +538,13 @@ "normal", "plaintext" ], + "user-select": [ + "all", + "auto", + "contain", + "none", + "text" + ], "vertical-align": [ "baseline", "bottom", diff --git a/Libraries/LibWeb/CSS/Properties.json b/Libraries/LibWeb/CSS/Properties.json index 0eb24a275d2..083b184f07d 100644 --- a/Libraries/LibWeb/CSS/Properties.json +++ b/Libraries/LibWeb/CSS/Properties.json @@ -2825,12 +2825,8 @@ "animation-type": "discrete", "inherited": false, "initial": "auto", - "valid-identifiers": [ - "all", - "auto", - "contain", - "none", - "text" + "valid-types": [ + "user-select" ] }, "vertical-align": { diff --git a/Libraries/LibWeb/Layout/Node.cpp b/Libraries/LibWeb/Layout/Node.cpp index 7c750e82923..9b2c1398884 100644 --- a/Libraries/LibWeb/Layout/Node.cpp +++ b/Libraries/LibWeb/Layout/Node.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -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(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; +} + } diff --git a/Libraries/LibWeb/Layout/Node.h b/Libraries/LibWeb/Layout/Node.h index 85a272f79ff..118feec5a59 100644 --- a/Libraries/LibWeb/Layout/Node.h +++ b/Libraries/LibWeb/Layout/Node.h @@ -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*); diff --git a/Libraries/LibWeb/Page/EventHandler.cpp b/Libraries/LibWeb/Page/EventHandler.cpp index 0c789c46465..519dfb2197d 100644 --- a/Libraries/LibWeb/Page/EventHandler.cpp +++ b/Libraries/LibWeb/Page/EventHandler.cpp @@ -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 anchor_node, unsigned anchor_offset, GC::Ptr 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& navigable) : m_navigable(navigable) , m_drag_and_drop_event_handler(make()) @@ -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()); } } } diff --git a/Libraries/LibWeb/Painting/ViewportPaintable.cpp b/Libraries/LibWeb/Painting/ViewportPaintable.cpp index 967a592bf59..439071d52fe 100644 --- a/Libraries/LibWeb/Painting/ViewportPaintable.cpp +++ b/Libraries/LibWeb/Painting/ViewportPaintable.cpp @@ -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(*end_container)) + if (is(*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); } diff --git a/Tests/LibWeb/Text/expected/wpt-import/css/css-ui/parsing/user-select-valid.txt b/Tests/LibWeb/Text/expected/wpt-import/css/css-ui/parsing/user-select-valid.txt new file mode 100644 index 00000000000..a004e9cf755 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/css/css-ui/parsing/user-select-valid.txt @@ -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 \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/css/css-ui/parsing/user-select-valid.html b/Tests/LibWeb/Text/input/wpt-import/css/css-ui/parsing/user-select-valid.html new file mode 100644 index 00000000000..6c1652eb073 --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/css/css-ui/parsing/user-select-valid.html @@ -0,0 +1,21 @@ + + + + +CSS UI Level 4: parsing user-select with valid values + + + + + + + + + +