Skip to content

Commit 03f6f25

Browse files
sideshowbarkerAtkinsSJ
authored andcommitted
LibWeb: Skip user-select:none when extracting selected text
Problem: Select All (Ctrl+A or the context menu) followed by Ctrl+C unexpectedly copies text from elements with user-select:none — breaking compat with Chrome and Firefox, which both exclude user-select:none content from the clipboard. Cause: Navigable::selected_text() walks the selection range via visible_text_in_range() and concatenates each text node’s data. The walk filters out nodes without a layout, but not nodes whose used value of user-select is ‘none’. Fix: Add a user-select check at each visible_text_in_range() walk point. The Selection range itself is left unchanged. Selection.toString() still returns the full text per spec — but the clipboard-extraction path now excludes user-select: none subtrees, per spec
1 parent a5b925f commit 03f6f25

6 files changed

Lines changed: 41 additions & 5 deletions

File tree

Libraries/LibWeb/HTML/Navigable.cpp

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3241,25 +3241,32 @@ bool Navigable::is_focused() const
32413241

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

3249+
auto is_user_select_none = [](DOM::Node const& node) {
3250+
auto const* layout = node.layout_node();
3251+
return layout && layout->user_select_used_value() == CSS::UserSelect::None;
3252+
};
3253+
32473254
if (range.start_container() == range.end_container() && is<DOM::Text>(*range.start_container())) {
3248-
if (!range.start_container()->layout_node())
3255+
if (!range.start_container()->layout_node() || is_user_select_none(*range.start_container()))
32493256
return String {};
32503257
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();
32513258
}
32523259

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

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

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

32653272
return MUST(builder.to_string());

Libraries/LibWeb/Internals/Internals.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,11 @@ String Internals::current_cursor()
410410
});
411411
}
412412

413+
String Internals::selected_text_for_clipboard()
414+
{
415+
return page().focused_navigable().selected_text();
416+
}
417+
413418
WebIDL::ExceptionOr<bool> Internals::dispatch_user_activated_event(DOM::EventTarget& target, DOM::Event& event)
414419
{
415420
event.set_is_trusted(true);

Libraries/LibWeb/Internals/Internals.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class WEB_API Internals final : public InternalsBase {
7070

7171
String current_cursor();
7272

73+
String selected_text_for_clipboard();
74+
7375
WebIDL::ExceptionOr<bool> dispatch_user_activated_event(DOM::EventTarget&, DOM::Event& event);
7476

7577
void spoof_current_url(String const& url);

Libraries/LibWeb/Internals/Internals.idl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ interface Internals {
4545

4646
DOMString currentCursor();
4747

48+
// Returns the text that would be placed on the clipboard if the user copied the current selection now.
49+
// Excludes nodes whose used value of user-select is 'none' — mirroring what browsers actually copy.
50+
DOMString selectedTextForClipboard();
51+
4852
boolean dispatchUserActivatedEvent(EventTarget target, Event event);
4953
undefined spoofCurrentURL(USVString url);
5054

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
selection.toString(): "hiddenvisiblealso hiddenalso visible"
2+
clipboard text: "visiblealso visible"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
<script src="include.js"></script>
3+
<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>
4+
<script>
5+
test(() => {
6+
getSelection().selectAllChildren(document.getElementById("container"));
7+
8+
// Per the Selection API spec, the Range itself still spans the whole container, including user-select:none
9+
// subtrees — so Selection.toString() returns everything.
10+
println(`selection.toString(): "${getSelection().toString().replace(/\n/g, "\\n")}"`);
11+
12+
// But the clipboard/visible-selection path must exclude user-select: none content, per
13+
// https://drafts.csswg.org/css-ui/#valdef-user-select-none.
14+
println(`clipboard text: "${internals.selectedTextForClipboard().replace(/\n/g, "\\n")}"`);
15+
});
16+
</script>

0 commit comments

Comments
 (0)