LibWeb: Selection toString focused text control delegation

Allows selected text in form controls to be copied to clipboard.
This commit is contained in:
Jonathan Gamble
2025-12-31 14:34:58 -06:00
committed by Shannon Booth
parent 451177f1f4
commit 7385569a02
Notes: github-actions[bot] 2026-01-02 17:41:12 +00:00
12 changed files with 140 additions and 4 deletions

View File

@@ -890,6 +890,18 @@ void FormAssociatedTextControlElement::handle_delete(DeleteDirection direction)
did_edit_text_node();
}
Optional<Utf16String> FormAssociatedTextControlElement::selected_text_for_stringifier() const
{
// https://w3c.github.io/selection-api/#dom-selection-stringifier
// Used for clipboard copy and window.getSelection().toString() when this element is active.
size_t start = this->selection_start();
size_t end = this->selection_end();
if (start >= end)
return {};
return Utf16String::from_utf16(relevant_value().substring_view(start, end - start));
}
void FormAssociatedTextControlElement::collapse_selection_to_offset(size_t position)
{
m_selection_start = position;

View File

@@ -197,8 +197,9 @@ class WEB_API FormAssociatedTextControlElement
, public InputEventsTarget {
public:
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-relevant-value
virtual Utf16String relevant_value() = 0;
virtual Utf16String relevant_value() const = 0;
virtual WebIDL::ExceptionOr<void> set_relevant_value(Utf16String const&) = 0;
virtual Optional<Utf16String> selected_text_for_stringifier() const;
virtual void set_dirty_value_flag(bool flag) = 0;

View File

@@ -1447,6 +1447,9 @@ void HTMLInputElement::did_receive_focus()
if (m_placeholder_text_node)
m_placeholder_text_node->invalidate_style(DOM::StyleInvalidationReason::DidReceiveFocus);
if (has_selectable_text())
document().get_selection()->remove_all_ranges();
}
void HTMLInputElement::did_lose_focus()
@@ -3298,6 +3301,7 @@ bool HTMLInputElement::has_selectable_text() const
case TypeAttributeState::Time:
case TypeAttributeState::LocalDateAndTime:
case TypeAttributeState::Number:
case TypeAttributeState::Email:
return true;
default:
return false;
@@ -3790,4 +3794,31 @@ bool HTMLInputElement::uses_button_layout() const
TypeAttributeState::Button, TypeAttributeState::Color, TypeAttributeState::FileUpload);
}
Optional<Utf16String> HTMLInputElement::selected_text_for_stringifier() const
{
// https://w3c.github.io/selection-api/#dom-selection-stringifier
// Used for clipboard copy and window.getSelection().toString() when this element is active.
if (!has_selectable_text())
return {};
size_t start = this->selection_start();
size_t end = this->selection_end();
if (start >= end)
return {};
switch (type_state()) {
case TypeAttributeState::Text:
case TypeAttributeState::Search:
case TypeAttributeState::Telephone:
case TypeAttributeState::URL:
case TypeAttributeState::Email:
return Utf16String::from_utf16(relevant_value().substring_view(start, end - start));
case TypeAttributeState::Password:
return Utf16String::repeated(0x2022, end - start); // 0x2022 is BULLET character
default:
return {};
}
}
}

View File

@@ -85,8 +85,9 @@ public:
WebIDL::ExceptionOr<void> set_value(Utf16String const&);
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-relevant-value
virtual Utf16String relevant_value() override { return value(); }
virtual Utf16String relevant_value() const override { return value(); }
WebIDL::ExceptionOr<void> set_relevant_value(Utf16String const& value) override { return set_value(value); }
virtual Optional<Utf16String> selected_text_for_stringifier() const override;
virtual void set_dirty_value_flag(bool flag) override { m_dirty_value = flag; }

View File

@@ -81,6 +81,8 @@ void HTMLTextAreaElement::did_receive_focus()
if (m_placeholder_text_node)
m_placeholder_text_node->invalidate_style(DOM::StyleInvalidationReason::DidReceiveFocus);
document().get_selection()->remove_all_ranges();
}
void HTMLTextAreaElement::did_lose_focus()

View File

@@ -88,7 +88,7 @@ public:
Utf16String api_value() const;
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-relevant-value
virtual Utf16String relevant_value() override { return api_value(); }
virtual Utf16String relevant_value() const override { return api_value(); }
virtual WebIDL::ExceptionOr<void> set_relevant_value(Utf16String const& value) override;
virtual void set_dirty_value_flag(bool flag) override { m_dirty_value = flag; }

View File

@@ -29,6 +29,7 @@
#include <LibWeb/HTML/BrowsingContextGroup.h>
#include <LibWeb/HTML/DocumentState.h>
#include <LibWeb/HTML/HTMLIFrameElement.h>
#include <LibWeb/HTML/HTMLInputElement.h>
#include <LibWeb/HTML/HistoryHandlingBehavior.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/HTML/Navigation.h>
@@ -2630,7 +2631,16 @@ String Navigable::selected_text() const
auto document = active_document();
if (!document)
return String {};
auto const* input_element = as_if<HTML::HTMLInputElement>(document->active_element());
if (input_element && input_element->type_state() == HTML::HTMLInputElement::TypeAttributeState::Password) {
// Apparently nobody wants bullet characters. We leave the clipboard alone here like other browsers.
return String {};
}
auto selection = document->get_selection();
if (auto form_text = selection->try_form_control_selected_text_for_stringifier(); form_text.has_value())
return form_text->to_utf8();
auto range = selection->range();
if (!range)
return String {};

View File

@@ -14,6 +14,7 @@
#include <LibWeb/DOM/Range.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/GraphemeEdgeTracker.h>
#include <LibWeb/HTML/FormAssociatedElement.h>
#include <LibWeb/Selection/Selection.h>
namespace Web::Selection {
@@ -505,8 +506,27 @@ bool Selection::contains_node(GC::Ref<DOM::Node> node, bool allow_partial_contai
&& (end_relative_position == DOM::RelativeBoundaryPointPosition::Equal || end_relative_position == DOM::RelativeBoundaryPointPosition::After);
}
Optional<Utf16String> Selection::try_form_control_selected_text_for_stringifier() const
{
// FIXME: According to https://bugzilla.mozilla.org/show_bug.cgi?id=85686#c69,
// sometimes you want the selection from a previously focused form text, probably
// when a button or context menu has temporarily stolen focus but page scripts
// still expect window.getSelection() to have the goodies.
auto const* form_element = as_if<HTML::FormAssociatedTextControlElement>(m_document->active_element());
if (!form_element)
return {};
return form_element->selected_text_for_stringifier();
}
Utf16String Selection::to_string() const
{
// https://w3c.github.io/selection-api/#dom-selection-stringifier
// If the selection is within a textarea or input element, it must return the
// selected substring in its value.
if (auto form_text = try_form_control_selected_text_for_stringifier(); form_text.has_value())
return form_text.release_value();
// FIXME: This needs more work to be compatible with other engines.
// See https://www.w3.org/Bugs/Public/show_bug.cgi?id=10583
if (!m_range)

View File

@@ -56,6 +56,7 @@ public:
// Non-standard convenience accessor for the selection's range.
GC::Ptr<DOM::Range> range() const;
Optional<Utf16String> try_form_control_selected_text_for_stringifier() const;
// Non-standard accessor for the selection's document.
GC::Ref<DOM::Document> document() const;

View File

@@ -763,7 +763,8 @@ void Application::initialize_actions()
m_copy_selection_action = Action::create("Copy"sv, ActionID::CopySelection, [this]() {
if (auto view = active_web_view(); view.has_value())
insert_clipboard_entry({ view->selected_text(), "text/plain"_string });
if (!view->selected_text().is_empty())
insert_clipboard_entry({ view->selected_text(), "text/plain"_string });
});
m_paste_action = Action::create("Paste"sv, ActionID::Paste, [this]() {
if (auto view = active_web_view(); view.has_value())

View File

@@ -0,0 +1,6 @@
initial, window.getSelection().toString() is ""
after selecting foo, window.getSelection().toString() is "foo"
after selecting bar, window.getSelection().toString() is "bar"
after unselecting bar, window.getSelection().toString() is ""
after selecting 'z' from normal text input, window.getSelection().toString() is "z"
after selecting 'boo' from password input, window.getSelection().toString() is "•••"

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Selection reflects textarea selection</title>
<script src="./include.js"></script>
<script>
test(() => {
function verify(expected, label) {
let s = window.getSelection().toString();
println(`${label}, window.getSelection().toString() is "${s}"`);
if (s !== expected)
throw new Error(`${label}: expected "${expected}", got "${s}"`);
}
verify("", "initial");
let foo = document.getElementById("foo");
let r = document.createRange();
r.selectNodeContents(foo);
let sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
verify("foo", "after selecting foo");
let textarea = document.getElementById("bar");
textarea.focus();
textarea.select();
verify("bar", "after selecting bar");
textarea.setSelectionRange(0, 0);
verify("", "after unselecting bar");
let input = document.getElementById("baz");
input.focus();
input.setSelectionRange(2, 3);
verify("z", "after selecting 'z' from normal text input");
let password = document.getElementById("boo");
password.focus();
password.setSelectionRange(0, 3);
verify("•••", "after selecting 'boo' from password input");
});
</script>
</head>
<body>
<p id="foo">foo</p>
<textarea id="bar">bar</textarea>
<input id="baz" type="text" value="baz">
<input id="boo" type="password" value="boo!">
</body>
</html>