Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions Libraries/LibWeb/HTML/Navigable.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3241,25 +3241,32 @@ bool Navigable::is_focused() const

static String visible_text_in_range(DOM::Range const& range)
{
// NOTE: This is an adaption of Range stringification, but we skip over DOM nodes that don't have a corresponding layout node.
// NOTE: This is an adaption of Range stringification — but we skip over DOM nodes that don't have a corresponding
// layout node, and over nodes whose used value of user-select is 'none'. The latter implements
// https://drafts.csswg.org/css-ui/#valdef-user-select-none — applied at the clipboard-extraction boundary.
StringBuilder builder;

auto is_user_select_none = [](DOM::Node const& node) {
auto const* layout = node.layout_node();
return layout && layout->user_select_used_value() == CSS::UserSelect::None;
};

if (range.start_container() == range.end_container() && is<DOM::Text>(*range.start_container())) {
if (!range.start_container()->layout_node())
if (!range.start_container()->layout_node() || is_user_select_none(*range.start_container()))
return String {};
return static_cast<DOM::Text const&>(*range.start_container()).data().substring_view(range.start_offset(), range.end_offset() - range.start_offset()).to_utf8_but_should_be_ported_to_utf16();
}

if (is<DOM::Text>(*range.start_container()) && range.start_container()->layout_node())
if (is<DOM::Text>(*range.start_container()) && range.start_container()->layout_node() && !is_user_select_none(*range.start_container()))
builder.append(static_cast<DOM::Text const&>(*range.start_container()).data().substring_view(range.start_offset()));

range.for_each_contained([&](GC::Ref<DOM::Node> node) {
if (is<DOM::Text>(*node) && node->layout_node())
if (is<DOM::Text>(*node) && node->layout_node() && !is_user_select_none(*node))
builder.append(static_cast<DOM::Text const&>(*node).data());
return IterationDecision::Continue;
});

if (is<DOM::Text>(*range.end_container()) && range.end_container()->layout_node())
if (is<DOM::Text>(*range.end_container()) && range.end_container()->layout_node() && !is_user_select_none(*range.end_container()))
builder.append(static_cast<DOM::Text const&>(*range.end_container()).data().substring_view(0, range.end_offset()));

return MUST(builder.to_string());
Expand Down
5 changes: 5 additions & 0 deletions Libraries/LibWeb/Internals/Internals.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,11 @@ String Internals::current_cursor()
});
}

String Internals::selected_text_for_clipboard()
{
return page().focused_navigable().selected_text();
}

WebIDL::ExceptionOr<bool> Internals::dispatch_user_activated_event(DOM::EventTarget& target, DOM::Event& event)
{
event.set_is_trusted(true);
Expand Down
2 changes: 2 additions & 0 deletions Libraries/LibWeb/Internals/Internals.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class WEB_API Internals final : public InternalsBase {

String current_cursor();

String selected_text_for_clipboard();

WebIDL::ExceptionOr<bool> dispatch_user_activated_event(DOM::EventTarget&, DOM::Event& event);

void spoof_current_url(String const& url);
Expand Down
4 changes: 4 additions & 0 deletions Libraries/LibWeb/Internals/Internals.idl
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ interface Internals {

DOMString currentCursor();

// Returns the text that would be placed on the clipboard if the user copied the current selection now.
// Excludes nodes whose used value of user-select is 'none' — mirroring what browsers actually copy.
DOMString selectedTextForClipboard();

boolean dispatchUserActivatedEvent(EventTarget target, Event event);
undefined spoofCurrentURL(USVString url);

Expand Down
19 changes: 15 additions & 4 deletions Libraries/LibWeb/Painting/ViewportPaintable.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,17 @@ void ViewportPaintable::recompute_selection_states(DOM::Range& range)
}
};

// https://drafts.csswg.org/css-ui/#valdef-user-select-none
// "The content of the element must be excluded from selection by [...] the selection methods of the Selection API
// and the like." We honor this by leaving such nodes at SelectionState::None — even when they fall inside the
// range. So, the selection highlight skips them.
auto is_excluded_from_selection = [](DOM::Node const& node) {
if (node.is_inert())
return true;
auto const* layout = node.unsafe_layout_node();
return layout && layout->user_select_used_value() == CSS::UserSelect::None;
};

auto start_container = range.start_container();
auto end_container = range.end_container();

Expand All @@ -324,14 +335,14 @@ void ViewportPaintable::recompute_selection_states(DOM::Range& range)
}

// 2. If it's a text node, mark it as StartAndEnd and return.
if (is<DOM::Text>(*start_container) && !range.start().node->is_inert()) {
if (is<DOM::Text>(*start_container) && !is_excluded_from_selection(*start_container)) {
set_selection_state_on_all_slices(*start_container, SelectionState::StartAndEnd);
return;
}
}

// 3. Mark the selection start node as Start (if text) or Full (if anything else).
if (!range.start().node->is_inert() && start_container->unsafe_layout_node()) {
if (!is_excluded_from_selection(*start_container) && start_container->unsafe_layout_node()) {
if (is<DOM::Text>(*start_container))
set_selection_state_on_all_slices(*start_container, SelectionState::Start);
else
Expand All @@ -352,13 +363,13 @@ void ViewportPaintable::recompute_selection_states(DOM::Range& range)
DOM::Node* stop_at = end_container->child_at_index(range.end_offset());
// Only stop at the end container if it has no children that may need to be included.
for (auto* node = start_at; node && (node != stop_at && !(node == end_container && !end_container->has_children())); node = node->next_in_pre_order(end_container)) {
if (node->is_inert())
if (is_excluded_from_selection(*node))
continue;
set_selection_state_on_all_slices(*node, SelectionState::Full);
}

// 5. Mark the selection end node as End if it is a text node.
if (!range.end().node->is_inert() && is<DOM::Text>(*end_container) && end_container->unsafe_layout_node()) {
if (!is_excluded_from_selection(*end_container) && is<DOM::Text>(*end_container) && end_container->unsafe_layout_node()) {
set_selection_state_on_all_slices(*end_container, SelectionState::End);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
selection.toString(): "hiddenvisiblealso hiddenalso visible"
clipboard text: "visiblealso visible"
16 changes: 16 additions & 0 deletions Tests/LibWeb/Text/input/select-all-respects-user-select-none.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<script src="include.js"></script>
<div id="container"><p style="user-select: none;">hidden</p><p>visible</p><p style="user-select: none;">also hidden</p><p>also visible</p></div>
<script>
test(() => {
getSelection().selectAllChildren(document.getElementById("container"));

// Per the Selection API spec, the Range itself still spans the whole container, including user-select:none
// subtrees — so Selection.toString() returns everything.
println(`selection.toString(): "${getSelection().toString().replace(/\n/g, "\\n")}"`);

// But the clipboard/visible-selection path must exclude user-select: none content, per
// https://drafts.csswg.org/css-ui/#valdef-user-select-none.
println(`clipboard text: "${internals.selectedTextForClipboard().replace(/\n/g, "\\n")}"`);
});
</script>
Loading