LibWeb: Do not scroll cursor into view on programmatic selection changes
We were mimicking Firefox' behavior that whenever a programmatic change to an <input>'s or <textarea>'s selection happened, the new selection focus is brought into view by scrolling. Currently we run a layout update synchronously for that to make sure we have the fragment's correct dimensions, which caused a significant performance regression in Speedometer. Since this is non-standard behavior, let's mimic Chromium instead which does not scroll at all - only for direct user initiated input such as typing. Relevant issues: * https://github.com/whatwg/html/issues/6217 * https://bugzilla.mozilla.org/show_bug.cgi?id=232405 * https://issues.chromium.org/issues/41081857
This commit is contained in:
parent
fa04c8db83
commit
ded42e649b
14 changed files with 124 additions and 69 deletions
|
|
@ -121,8 +121,8 @@ enum class InvalidateLayoutTreeReason {
|
|||
X(RangeGetClientRects) \
|
||||
X(ResolvedCSSStyleDeclarationProperty) \
|
||||
X(SVGDecodedImageDataRender) \
|
||||
X(ScrollCursorIntoView) \
|
||||
X(SVGGraphicsElementGetBBox) \
|
||||
X(ScrollFocusIntoView) \
|
||||
X(SourceSetNormalizeSourceDensities) \
|
||||
X(WindowScroll)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,9 +106,10 @@ void EditingHostManager::move_cursor_to_start(CollapseSelection collapse)
|
|||
if (collapse == CollapseSelection::Yes) {
|
||||
MUST(selection->collapse(node, 0));
|
||||
m_document->reset_cursor_blink_cycle();
|
||||
return;
|
||||
} else {
|
||||
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, 0));
|
||||
}
|
||||
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, 0));
|
||||
selection->scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void EditingHostManager::move_cursor_to_end(CollapseSelection collapse)
|
||||
|
|
@ -121,9 +122,10 @@ void EditingHostManager::move_cursor_to_end(CollapseSelection collapse)
|
|||
if (collapse == CollapseSelection::Yes) {
|
||||
m_document->reset_cursor_blink_cycle();
|
||||
MUST(selection->collapse(node, node->length()));
|
||||
return;
|
||||
} else {
|
||||
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, node->length()));
|
||||
}
|
||||
MUST(selection->set_base_and_extent(*selection->anchor_node(), selection->anchor_offset(), *node, node->length()));
|
||||
selection->scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void EditingHostManager::increment_cursor_position_offset(CollapseSelection collapse)
|
||||
|
|
|
|||
|
|
@ -132,6 +132,10 @@ WebIDL::ExceptionOr<bool> Document::exec_command(FlyString const& command, [[may
|
|||
affected_editing_host->dispatch_event(event);
|
||||
}
|
||||
|
||||
// AD-HOC: Scroll the cursor into view after executing a command.
|
||||
if (auto selection = get_selection())
|
||||
selection->scroll_focus_into_view();
|
||||
|
||||
// 8. Return true.
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -841,7 +841,7 @@ void FormAssociatedTextControlElement::set_the_selection_range(Optional<WebIDL::
|
|||
});
|
||||
}
|
||||
|
||||
selection_was_changed();
|
||||
selection_was_changed(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -872,6 +872,7 @@ void FormAssociatedTextControlElement::handle_insert(FlyString const& input_type
|
|||
data_for_input_event = data_for_insertion;
|
||||
|
||||
did_edit_text_node(input_type, data_for_input_event);
|
||||
scroll_cursor_into_view();
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::handle_delete(FlyString const& input_type)
|
||||
|
|
@ -897,6 +898,7 @@ void FormAssociatedTextControlElement::handle_delete(FlyString const& input_type
|
|||
|
||||
text_node->invalidate_style(DOM::StyleInvalidationReason::EditingDeletion);
|
||||
did_edit_text_node(input_type, {});
|
||||
scroll_cursor_into_view();
|
||||
}
|
||||
|
||||
Optional<Utf16String> FormAssociatedTextControlElement::selected_text_for_stringifier() const
|
||||
|
|
@ -920,7 +922,7 @@ void FormAssociatedTextControlElement::collapse_selection_to_offset(size_t posit
|
|||
void FormAssociatedTextControlElement::scroll_cursor_into_view()
|
||||
{
|
||||
auto& element = form_associated_element_to_html_element();
|
||||
element.document().update_layout(DOM::UpdateLayoutReason::ScrollFocusIntoView);
|
||||
element.document().update_layout(DOM::UpdateLayoutReason::ScrollCursorIntoView);
|
||||
|
||||
auto text_node = form_associated_element_to_text_node();
|
||||
if (!text_node)
|
||||
|
|
@ -933,7 +935,7 @@ void FormAssociatedTextControlElement::scroll_cursor_into_view()
|
|||
paintable->scroll_ancestor_to_offset_into_view(m_selection_end);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::selection_was_changed()
|
||||
void FormAssociatedTextControlElement::selection_was_changed(SelectionSource source)
|
||||
{
|
||||
auto& element = form_associated_element_to_html_element();
|
||||
if (auto* input_element = as_if<HTMLInputElement>(element)) {
|
||||
|
|
@ -959,10 +961,14 @@ void FormAssociatedTextControlElement::selection_was_changed()
|
|||
}
|
||||
text_paintable->set_needs_display();
|
||||
|
||||
// AD-HOC: Skip scroll-into-view during mouse selection, since the user controls the viewport.
|
||||
auto navigable = element.document().cached_navigable();
|
||||
if (!(navigable && navigable->event_handler().is_handling_mouse_selection()))
|
||||
scroll_cursor_into_view();
|
||||
// AD-HOC: Only scroll the cursor into view for UI-driven selection changes (like keyboard input). Programmatic
|
||||
// changes (input.value, setSelectionRange) do not cause the cursor to scroll into view. This matches the
|
||||
// behavior of other browsers.
|
||||
if (source == SelectionSource::UI) {
|
||||
auto navigable = element.document().navigable();
|
||||
if (navigable && !navigable->event_handler().is_handling_mouse_selection())
|
||||
scroll_cursor_into_view();
|
||||
}
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::select_all()
|
||||
|
|
@ -971,7 +977,7 @@ void FormAssociatedTextControlElement::select_all()
|
|||
if (!text_node)
|
||||
return;
|
||||
set_the_selection_range(0, text_node->length());
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::set_selection_anchor(GC::Ref<DOM::Node> anchor_node, size_t anchor_offset)
|
||||
|
|
@ -982,7 +988,7 @@ void FormAssociatedTextControlElement::set_selection_anchor(GC::Ref<DOM::Node> a
|
|||
if (!text_node || anchor_node != text_node)
|
||||
return;
|
||||
collapse_selection_to_offset(anchor_offset);
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::set_selection_focus(GC::Ref<DOM::Node> focus_node, size_t focus_offset)
|
||||
|
|
@ -993,7 +999,7 @@ void FormAssociatedTextControlElement::set_selection_focus(GC::Ref<DOM::Node> fo
|
|||
if (!text_node || focus_node != text_node)
|
||||
return;
|
||||
m_selection_end = focus_offset;
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::move_cursor_to_start(CollapseSelection collapse)
|
||||
|
|
@ -1006,7 +1012,7 @@ void FormAssociatedTextControlElement::move_cursor_to_start(CollapseSelection co
|
|||
} else {
|
||||
m_selection_end = 0;
|
||||
}
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::move_cursor_to_end(CollapseSelection collapse)
|
||||
|
|
@ -1019,7 +1025,7 @@ void FormAssociatedTextControlElement::move_cursor_to_end(CollapseSelection coll
|
|||
} else {
|
||||
m_selection_end = text_node->length();
|
||||
}
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::increment_cursor_position_offset(CollapseSelection collapse)
|
||||
|
|
@ -1034,7 +1040,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_offset(Collapse
|
|||
m_selection_end = *offset;
|
||||
}
|
||||
}
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::decrement_cursor_position_offset(CollapseSelection collapse)
|
||||
|
|
@ -1049,7 +1055,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_offset(Collapse
|
|||
m_selection_end = *offset;
|
||||
}
|
||||
}
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::increment_cursor_position_to_next_word(CollapseSelection collapse)
|
||||
|
|
@ -1072,7 +1078,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_to_next_word(Co
|
|||
break;
|
||||
}
|
||||
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_word(CollapseSelection collapse)
|
||||
|
|
@ -1095,7 +1101,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_wor
|
|||
break;
|
||||
}
|
||||
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::increment_cursor_position_to_next_line(CollapseSelection collapse)
|
||||
|
|
@ -1113,7 +1119,7 @@ void FormAssociatedTextControlElement::increment_cursor_position_to_next_line(Co
|
|||
else
|
||||
m_selection_end = *new_offset;
|
||||
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_line(CollapseSelection collapse)
|
||||
|
|
@ -1131,7 +1137,7 @@ void FormAssociatedTextControlElement::decrement_cursor_position_to_previous_lin
|
|||
else
|
||||
m_selection_end = *new_offset;
|
||||
|
||||
selection_was_changed();
|
||||
selection_was_changed(SelectionSource::UI);
|
||||
}
|
||||
|
||||
GC::Ptr<DOM::Position> FormAssociatedTextControlElement::cursor_position() const
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ private:
|
|||
|
||||
void collapse_selection_to_offset(size_t);
|
||||
void scroll_cursor_into_view();
|
||||
void selection_was_changed();
|
||||
void selection_was_changed(SelectionSource);
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-textarea/input-selection
|
||||
WebIDL::UnsignedLong m_selection_start { 0 };
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@
|
|||
#include <LibWeb/DOM/Text.h>
|
||||
#include <LibWeb/GraphemeEdgeTracker.h>
|
||||
#include <LibWeb/HTML/FormAssociatedElement.h>
|
||||
#include <LibWeb/HTML/Navigable.h>
|
||||
#include <LibWeb/Page/EventHandler.h>
|
||||
#include <LibWeb/Painting/Paintable.h>
|
||||
#include <LibWeb/Selection/Selection.h>
|
||||
|
||||
|
|
@ -579,12 +577,6 @@ void Selection::set_range(GC::Ptr<DOM::Range> range)
|
|||
HTML::run_focusing_steps(new_editing_host, nullptr, HTML::FocusTrigger::Other);
|
||||
}
|
||||
}
|
||||
|
||||
// AD-HOC: Scroll the focus position into view within the nearest scrollable ancestor.
|
||||
// Skip this during mouse selection, since the user controls the viewport.
|
||||
auto navigable = m_document->cached_navigable();
|
||||
if (range && !(navigable && navigable->event_handler().is_handling_mouse_selection()))
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
|
||||
GC::Ptr<DOM::Position> Selection::cursor_position() const
|
||||
|
|
@ -612,6 +604,7 @@ void Selection::move_offset_to_next_character(bool collapse_selection)
|
|||
} else {
|
||||
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *offset));
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -628,6 +621,7 @@ void Selection::move_offset_to_previous_character(bool collapse_selection)
|
|||
} else {
|
||||
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *offset));
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -640,7 +634,7 @@ void Selection::move_offset_to_next_word(bool collapse_selection)
|
|||
while (true) {
|
||||
auto focus_offset = this->focus_offset();
|
||||
if (focus_offset == text_node->data().length_in_code_units())
|
||||
return;
|
||||
break;
|
||||
|
||||
if (auto offset = text_node->word_segmenter().next_boundary(focus_offset); offset.has_value()) {
|
||||
auto word = text_node->data().substring_view(focus_offset, *offset - focus_offset);
|
||||
|
|
@ -655,6 +649,7 @@ void Selection::move_offset_to_next_word(bool collapse_selection)
|
|||
}
|
||||
break;
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void Selection::move_offset_to_previous_word(bool collapse_selection)
|
||||
|
|
@ -678,6 +673,7 @@ void Selection::move_offset_to_previous_word(bool collapse_selection)
|
|||
}
|
||||
break;
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void Selection::move_offset_to_next_line(bool collapse_selection)
|
||||
|
|
@ -696,6 +692,7 @@ void Selection::move_offset_to_next_line(bool collapse_selection)
|
|||
} else {
|
||||
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *new_offset));
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void Selection::move_offset_to_previous_line(bool collapse_selection)
|
||||
|
|
@ -714,6 +711,7 @@ void Selection::move_offset_to_previous_line(bool collapse_selection)
|
|||
} else {
|
||||
MUST(set_base_and_extent(*text_node, anchor_offset(), *text_node, *new_offset));
|
||||
}
|
||||
scroll_focus_into_view();
|
||||
}
|
||||
|
||||
void Selection::scroll_focus_into_view()
|
||||
|
|
@ -722,7 +720,7 @@ void Selection::scroll_focus_into_view()
|
|||
if (!focus)
|
||||
return;
|
||||
|
||||
m_document->update_layout(DOM::UpdateLayoutReason::ScrollFocusIntoView);
|
||||
m_document->update_layout(DOM::UpdateLayoutReason::ScrollCursorIntoView);
|
||||
|
||||
auto* paintable = focus->paintable();
|
||||
if (!paintable)
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ public:
|
|||
GC::Ptr<DOM::Position> cursor_position() const;
|
||||
|
||||
// Non-standard
|
||||
void scroll_focus_into_view();
|
||||
void move_offset_to_next_character(bool collapse_selection);
|
||||
void move_offset_to_previous_character(bool collapse_selection);
|
||||
void move_offset_to_next_word(bool collapse_selection);
|
||||
|
|
@ -81,7 +82,6 @@ private:
|
|||
virtual void visit_edges(Cell::Visitor&) override;
|
||||
|
||||
void set_range(GC::Ptr<DOM::Range>);
|
||||
void scroll_focus_into_view();
|
||||
|
||||
// https://w3c.github.io/selection-api/#dfn-empty
|
||||
GC::Ptr<DOM::Range> m_range;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
PASS
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
textarea scrollLeft: 249.28125
|
||||
div scrollLeft: 251.28125
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
vertical scrollTop: 80
|
||||
horizontal scrollLeft: 603.171875
|
||||
|
|
@ -22,9 +22,8 @@
|
|||
internals.click(x, y);
|
||||
println(`before: ${input.selectionStart}`);
|
||||
|
||||
// Move cursor to the end, which should scroll the input.
|
||||
input.setSelectionRange(39, 39);
|
||||
input.offsetWidth;
|
||||
// Move cursor to the end via keyboard, which should scroll the input.
|
||||
internals.sendKey(input, "End");
|
||||
|
||||
// Click in the same spot after scrolling.
|
||||
internals.click(x, y);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
<input id="inp" />
|
||||
<div id="app"></div>
|
||||
<script src="include.js"></script>
|
||||
<script>
|
||||
test(() => {
|
||||
const input = document.querySelector("#inp");
|
||||
const app = document.querySelector("#app");
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row";
|
||||
row.innerHTML = `<input type="checkbox"><span>${i}</span><button>x</button>`;
|
||||
app.appendChild(row);
|
||||
}
|
||||
input.focus();
|
||||
input.offsetWidth;
|
||||
|
||||
// Performance test: setting input.value must not synchronously trigger a full layout.
|
||||
const values = ["a", "ab"];
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
app.children[i % 2000].style.padding = (4 + (i % 2)) + "px";
|
||||
input.value = values[i % 2];
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
println(elapsed < 5000 ? "PASS" : `FAIL: took ${elapsed.toFixed(0)}ms`);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<style>
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
width: 100px;
|
||||
height: 1.5em;
|
||||
padding: 2px;
|
||||
border: 1px solid;
|
||||
resize: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
div[contenteditable] {
|
||||
font-size: 16px;
|
||||
width: 100px;
|
||||
height: 1.5em;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<textarea>foobarbazloremipsum foobarbazloremipsum</textarea>
|
||||
<div contenteditable>foobarbazloremipsum foobarbazloremipsum</div>
|
||||
<script src="include.js"></script>
|
||||
<script>
|
||||
test(() => {
|
||||
const textarea = document.querySelector("textarea");
|
||||
const div = document.querySelector("div[contenteditable]");
|
||||
textarea.offsetWidth;
|
||||
|
||||
textarea.focus();
|
||||
internals.sendKey(textarea, "End");
|
||||
println(`textarea scrollLeft: ${textarea.scrollLeft}`);
|
||||
|
||||
div.focus();
|
||||
internals.sendKey(div, "End");
|
||||
println(`div scrollLeft: ${div.scrollLeft}`);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<style>
|
||||
div {
|
||||
overflow: auto;
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<div id="vertical">line1<br>line2<br>line3<br>line4<br>line5<br>line6<br>line7<br>line8<br>line9<br>line10</div>
|
||||
<div id="horizontal" style="white-space: nowrap">lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore</div>
|
||||
<script src="include.js"></script>
|
||||
<script>
|
||||
test(() => {
|
||||
document.body.offsetWidth; // force layout
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
// Vertical: set selection focus to end of content.
|
||||
const lastTextV = vertical.lastChild;
|
||||
sel.setBaseAndExtent(lastTextV, 0, lastTextV, lastTextV.length);
|
||||
println(`vertical scrollTop: ${vertical.scrollTop}`);
|
||||
|
||||
// Horizontal: set selection focus to end of content.
|
||||
const textH = horizontal.firstChild;
|
||||
sel.setBaseAndExtent(textH, 0, textH, textH.length);
|
||||
println(`horizontal scrollLeft: ${horizontal.scrollLeft}`);
|
||||
});
|
||||
</script>
|
||||
Loading…
Reference in a new issue