mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2025-05-04 18:23:39 +00:00
LibWeb: Separate text control input events handling from contenteditable
This input event handling change is intended to address the following design issues: - Having `DOM::Position` is unnecessary complexity when `Selection` exists because caret position could be described by the selection object with a collapsed state. Before this change, we had to synchronize those whenever one of them was modified, and there were already bugs caused by that, i.e., caret position was not changed when selection offset was modified from the JS side. - Selection API exposes selection offset within `<textarea>` and `<input>`, which is not supposed to happen. These objects should manage their selection state by themselves and have selection offset even when they are not displayed. - `EventHandler` looks only at `DOM::Text` owned by `DOM::Position` while doing text manipulations. It works fine for `<input>` and `<textarea>`, but `contenteditable` needs to consider all text descendant text nodes; i.e., if the cursor is moved outside of `DOM::Text`, we need to look for an adjacent text node to move the cursor there. With this change, `EventHandler` no longer does direct manipulations on caret position or text content, but instead delegates them to the active `InputEventsTarget`, which could be either `FormAssociatedTextControlElement` (for `<input>` and `<textarea>`) or `EditingHostManager` (for `contenteditable`). The `Selection` object is used to manage both selection and caret position for `contenteditable`, and text control elements manage their own selection state that is not exposed by Selection API. This change improves text editing on Discord, as now we don't have to refocus the `contenteditable` element after character input. The problem was that selection manipulations from the JS side were not propagated to `DOM::Position`. I expect this change to make future correctness improvements for `contenteditable` (and `designMode`) easier, as now it's decoupled from `<input>` and `<textarea>` and separated from `EventHandler`, which is quite a busy file.
This commit is contained in:
parent
380907cd48
commit
a8077f79cc
Notes:
github-actions[bot]
2024-10-30 18:30:49 +00:00
Author: https://github.com/kalenikaliaksandr
Commit: a8077f79cc
Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/2024
35 changed files with 884 additions and 663 deletions
|
@ -6,8 +6,11 @@
|
|||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <LibUnicode/CharacterTypes.h>
|
||||
#include <LibUnicode/Segmenter.h>
|
||||
#include <LibWeb/DOM/Document.h>
|
||||
#include <LibWeb/DOM/Event.h>
|
||||
#include <LibWeb/DOM/Position.h>
|
||||
#include <LibWeb/HTML/FormAssociatedElement.h>
|
||||
#include <LibWeb/HTML/HTMLButtonElement.h>
|
||||
#include <LibWeb/HTML/HTMLFieldSetElement.h>
|
||||
|
@ -17,6 +20,7 @@
|
|||
#include <LibWeb/HTML/HTMLSelectElement.h>
|
||||
#include <LibWeb/HTML/HTMLTextAreaElement.h>
|
||||
#include <LibWeb/HTML/Parser/HTMLParser.h>
|
||||
#include <LibWeb/Painting/Paintable.h>
|
||||
|
||||
namespace Web::HTML {
|
||||
|
||||
|
@ -196,7 +200,7 @@ WebIDL::ExceptionOr<void> FormAssociatedElement::set_form_action(String const& v
|
|||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-relevant-value
|
||||
void FormAssociatedTextControlElement::relevant_value_was_changed(JS::GCPtr<DOM::Text> text_node)
|
||||
void FormAssociatedTextControlElement::relevant_value_was_changed()
|
||||
{
|
||||
auto the_relevant_value = relevant_value();
|
||||
auto relevant_value_length = the_relevant_value.code_points().length();
|
||||
|
@ -223,13 +227,8 @@ void FormAssociatedTextControlElement::relevant_value_was_changed(JS::GCPtr<DOM:
|
|||
|
||||
// 2. Otherwise, the element must have a text entry cursor position position. If it is now past
|
||||
// the end of the relevant value, set it to the end of the relevant value.
|
||||
auto& document = form_associated_element_to_html_element().document();
|
||||
auto const current_cursor_position = document.cursor_position();
|
||||
if (current_cursor_position && text_node
|
||||
&& current_cursor_position->node() == text_node
|
||||
&& current_cursor_position->offset() > relevant_value_length) {
|
||||
document.set_cursor_position(DOM::Position::create(document.realm(), *text_node, relevant_value_length));
|
||||
}
|
||||
if (m_selection_start > relevant_value_length)
|
||||
m_selection_start = relevant_value_length;
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea/input-select
|
||||
|
@ -263,13 +262,12 @@ Optional<WebIDL::UnsignedLong> FormAssociatedTextControlElement::selection_start
|
|||
// 2. If there is no selection, return the code unit offset within the relevant value to the character that
|
||||
// immediately follows the text entry cursor.
|
||||
if (m_selection_start == m_selection_end) {
|
||||
if (auto cursor = form_associated_element_to_html_element().document().cursor_position())
|
||||
return cursor->offset();
|
||||
return m_selection_start;
|
||||
}
|
||||
|
||||
// 3. Return the code unit offset within the relevant value to the character that immediately follows the start of
|
||||
// the selection.
|
||||
return m_selection_start;
|
||||
return m_selection_start < m_selection_end ? m_selection_start : m_selection_end;
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection:dom-textarea/input-selectionstart-2
|
||||
|
@ -312,13 +310,12 @@ Optional<WebIDL::UnsignedLong> FormAssociatedTextControlElement::selection_end()
|
|||
// 2. If there is no selection, return the code unit offset within the relevant value to the
|
||||
// character that immediately follows the text entry cursor.
|
||||
if (m_selection_start == m_selection_end) {
|
||||
if (auto cursor = form_associated_element_to_html_element().document().cursor_position())
|
||||
return cursor->offset();
|
||||
return m_selection_start;
|
||||
}
|
||||
|
||||
// 3. Return the code unit offset within the relevant value to the character that immediately
|
||||
// follows the end of the selection.
|
||||
return m_selection_end;
|
||||
return m_selection_start < m_selection_end ? m_selection_end : m_selection_start;
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection:dom-textarea/input-selectionend-3
|
||||
|
@ -589,4 +586,240 @@ void FormAssociatedTextControlElement::set_the_selection_range(Optional<WebIDL::
|
|||
}
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::handle_insert(String const& data)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node || !text_node->is_editable())
|
||||
return;
|
||||
|
||||
String data_for_insertion = data;
|
||||
if (auto max_length = text_node->max_length(); max_length.has_value()) {
|
||||
auto remaining_length = *max_length - text_node->data().code_points().length();
|
||||
if (remaining_length < data.code_points().length()) {
|
||||
data_for_insertion = MUST(data.substring_from_byte_offset(0, remaining_length));
|
||||
}
|
||||
}
|
||||
auto selection_start = this->selection_start();
|
||||
auto selection_end = this->selection_end();
|
||||
if (!selection_start.has_value() || !selection_end.has_value()) {
|
||||
return;
|
||||
}
|
||||
MUST(set_range_text(data_for_insertion, selection_start.value(), selection_end.value(), Bindings::SelectionMode::End));
|
||||
|
||||
text_node->invalidate_style(DOM::StyleInvalidationReason::EditingInsertion);
|
||||
text_node->editable_text_node_owner()->did_edit_text_node();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::handle_delete(DeleteDirection direction)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node || !text_node->is_editable())
|
||||
return;
|
||||
auto selection_start = this->selection_start();
|
||||
auto selection_end = this->selection_end();
|
||||
if (!selection_start.has_value() || !selection_end.has_value()) {
|
||||
return;
|
||||
}
|
||||
if (selection_start == selection_end) {
|
||||
if (direction == DeleteDirection::Backward) {
|
||||
if (selection_start.value() > 0) {
|
||||
MUST(set_range_text(MUST(String::from_utf8(""sv)), selection_start.value() - 1, selection_end.value(), Bindings::SelectionMode::End));
|
||||
}
|
||||
} else {
|
||||
if (selection_start.value() < text_node->data().code_points().length()) {
|
||||
MUST(set_range_text(MUST(String::from_utf8(""sv)), selection_start.value(), selection_end.value() + 1, Bindings::SelectionMode::End));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
MUST(set_range_text(MUST(String::from_utf8(""sv)), selection_start.value(), selection_end.value(), Bindings::SelectionMode::End));
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::handle_return_key()
|
||||
{
|
||||
auto& html_element = form_associated_element_to_html_element();
|
||||
if (is<HTMLInputElement>(html_element)) {
|
||||
auto& input_element = static_cast<HTMLInputElement&>(html_element);
|
||||
if (auto* form = input_element.form()) {
|
||||
form->implicitly_submit_form().release_value_but_fixme_should_propagate_errors();
|
||||
return;
|
||||
}
|
||||
input_element.commit_pending_changes();
|
||||
}
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::collapse_selection_to_offset(size_t position)
|
||||
{
|
||||
m_selection_start = position;
|
||||
m_selection_end = position;
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::selection_was_changed()
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
auto* text_paintable = text_node->paintable();
|
||||
if (!text_paintable)
|
||||
return;
|
||||
if (m_selection_start == m_selection_end) {
|
||||
text_paintable->set_selected(false);
|
||||
text_paintable->set_selection_state(Painting::Paintable::SelectionState::None);
|
||||
text_node->document().reset_cursor_blink_cycle();
|
||||
} else {
|
||||
text_paintable->set_selected(true);
|
||||
text_paintable->set_selection_state(Painting::Paintable::SelectionState::StartAndEnd);
|
||||
}
|
||||
text_paintable->set_needs_display();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::select_all()
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
set_the_selection_range(0, text_node->length());
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::set_selection_anchor(JS::NonnullGCPtr<DOM::Node> anchor_node, size_t anchor_offset)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node || anchor_node != text_node)
|
||||
return;
|
||||
collapse_selection_to_offset(anchor_offset);
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::set_selection_focus(JS::NonnullGCPtr<DOM::Node> focus_node, size_t focus_offset)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node || focus_node != text_node)
|
||||
return;
|
||||
m_selection_end = focus_offset;
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::move_cursor_to_start(CollapseSelection collapse)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(0);
|
||||
} else {
|
||||
m_selection_end = 0;
|
||||
}
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::move_cursor_to_end(CollapseSelection collapse)
|
||||
{
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(text_node->length());
|
||||
} else {
|
||||
m_selection_end = text_node->length();
|
||||
}
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::increment_cursor_position_offset(CollapseSelection collapse)
|
||||
{
|
||||
auto const text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
if (auto offset = text_node->grapheme_segmenter().next_boundary(m_selection_end); offset.has_value()) {
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(*offset);
|
||||
} else {
|
||||
m_selection_end = *offset;
|
||||
}
|
||||
}
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::decrement_cursor_position_offset(CollapseSelection collapse)
|
||||
{
|
||||
auto const text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
if (auto offset = text_node->grapheme_segmenter().previous_boundary(m_selection_end); offset.has_value()) {
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(*offset);
|
||||
} else {
|
||||
m_selection_end = *offset;
|
||||
}
|
||||
}
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
static bool should_continue_beyond_word(Utf8View const& word)
|
||||
{
|
||||
for (auto code_point : word) {
|
||||
if (!Unicode::code_point_has_punctuation_general_category(code_point) && !Unicode::code_point_has_separator_general_category(code_point))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::increment_cursor_position_to_next_word(CollapseSelection collapse)
|
||||
{
|
||||
auto const text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
|
||||
while (true) {
|
||||
if (auto offset = text_node->word_segmenter().next_boundary(m_selection_end); offset.has_value()) {
|
||||
auto word = text_node->data().code_points().substring_view(m_selection_end, *offset - m_selection_end);
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(*offset);
|
||||
} else {
|
||||
m_selection_end = *offset;
|
||||
}
|
||||
if (should_continue_beyond_word(word))
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_word(CollapseSelection collapse)
|
||||
{
|
||||
auto const text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
return;
|
||||
|
||||
while (true) {
|
||||
if (auto offset = text_node->word_segmenter().previous_boundary(m_selection_end); offset.has_value()) {
|
||||
auto word = text_node->data().code_points().substring_view(m_selection_end, m_selection_end - *offset);
|
||||
if (collapse == CollapseSelection::Yes) {
|
||||
collapse_selection_to_offset(*offset);
|
||||
} else {
|
||||
m_selection_end = *offset;
|
||||
}
|
||||
if (should_continue_beyond_word(word))
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
selection_was_changed();
|
||||
}
|
||||
|
||||
JS::GCPtr<DOM::Position> FormAssociatedTextControlElement::cursor_position() const
|
||||
{
|
||||
auto const node = form_associated_element_to_text_node();
|
||||
if (!node)
|
||||
return nullptr;
|
||||
if (m_selection_start == m_selection_end)
|
||||
return DOM::Position::create(node->realm(), const_cast<DOM::Text&>(*node), m_selection_start);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue