LibWeb: Delete entire graphemes when the delete/backspace key is pressed

We currently delete a single code unit. If the user presses backspace on
a multi code point emoji, they are going to expect the entire emoji to
be removed. This now matches the behavior of Chrome and Firefox.
This commit is contained in:
Timothy Flynn 2025-08-14 15:25:19 -04:00 committed by Jelle Raaijmakers
commit c369f68eff
Notes: github-actions[bot] 2025-08-14 20:22:53 +00:00
8 changed files with 69 additions and 19 deletions

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-2-Clause * SPDX-License-Identifier: BSD-2-Clause
*/ */
#include <LibUnicode/Segmenter.h>
#include <LibWeb/CSS/StyleValues/KeywordStyleValue.h> #include <LibWeb/CSS/StyleValues/KeywordStyleValue.h>
#include <LibWeb/DOM/Comment.h> #include <LibWeb/DOM/Comment.h>
#include <LibWeb/DOM/Document.h> #include <LibWeb/DOM/Document.h>
@ -204,12 +205,17 @@ bool command_delete_action(DOM::Document& document, Utf16String const&)
block_node_child_is_relevant_type = child_element.local_name().is_one_of(HTML::TagNames::br, HTML::TagNames::hr, HTML::TagNames::img); block_node_child_is_relevant_type = child_element.local_name().is_one_of(HTML::TagNames::br, HTML::TagNames::hr, HTML::TagNames::img);
} }
} }
if ((is<DOM::Text>(*node) && offset != 0) || block_node_child_is_relevant_type) {
if (auto const* text_node = as_if<DOM::Text>(*node); (text_node && offset != 0) || block_node_child_is_relevant_type) {
auto start_offset = text_node
? text_node->grapheme_segmenter().previous_boundary(offset).value_or(offset - 1)
: offset - 1;
// 1. Call collapse(node, offset) on the context object's selection. // 1. Call collapse(node, offset) on the context object's selection.
MUST(selection.collapse(node, offset)); MUST(selection.collapse(node, offset));
// 2. Call extend(node, offset 1) on the context object's selection. // 2. Call extend(node, offset 1) on the context object's selection.
MUST(selection.extend(*node, offset - 1)); MUST(selection.extend(*node, start_offset));
// 3. Delete the selection. // 3. Delete the selection.
delete_the_selection(selection); delete_the_selection(selection);
@ -927,9 +933,9 @@ bool command_forward_delete_action(DOM::Document& document, Utf16String const&)
} }
// 5. If node is a Text node and offset is not node's length: // 5. If node is a Text node and offset is not node's length:
if (is<DOM::Text>(*node) && offset != node->length()) { if (auto const* text_node = as_if<DOM::Text>(*node); text_node && offset != node->length()) {
// 1. Let end offset be offset plus one. // 1. Let end offset be offset plus one.
auto end_offset = offset + 1; auto end_offset = text_node->grapheme_segmenter().next_boundary(offset).value_or(offset + 1);
// FIXME: 2. While end offset is not node's length and the end offsetth code unit of node's data has general category M // FIXME: 2. While end offset is not node's length and the end offsetth code unit of node's data has general category M
// when interpreted as a Unicode code point, add one to end offset. // when interpreted as a Unicode code point, add one to end offset.

View file

@ -829,13 +829,12 @@ void FormAssociatedTextControlElement::handle_delete(DeleteDirection direction)
if (selection_start == selection_end) { if (selection_start == selection_end) {
if (direction == DeleteDirection::Backward) { if (direction == DeleteDirection::Backward) {
if (selection_start > 0) if (auto offset = text_node->grapheme_segmenter().previous_boundary(m_selection_end); offset.has_value())
MUST(set_range_text({}, selection_start - 1, selection_end, Bindings::SelectionMode::End)); selection_start = *offset;
} else { } else {
if (selection_start < text_node->length_in_utf16_code_units()) if (auto offset = text_node->grapheme_segmenter().next_boundary(m_selection_end); offset.has_value())
MUST(set_range_text({}, selection_start, selection_end + 1, Bindings::SelectionMode::End)); selection_end = *offset;
} }
return;
} }
MUST(set_range_text({}, selection_start, selection_end, Bindings::SelectionMode::End)); MUST(set_range_text({}, selection_start, selection_end, Bindings::SelectionMode::End));

View file

@ -1,2 +1,6 @@
--- a ---
Before: foobar Before: foobar
After: fobar After: fobar
--- b ---
Before: foo👩🏼👨🏻bar
After: foobar

View file

@ -19,3 +19,6 @@ After: a&nbsp; &nbsp; b
--- g --- --- g ---
Before: &nbsp;&nbsp;b Before: &nbsp;&nbsp;b
After: &nbsp;b After: &nbsp;b
--- h ---
Before: foo👩🏼👨🏻bar
After: foobar

View file

@ -0,0 +1,6 @@
--- a ---
Before: foo👩🏼👨🏻bar
After: foobar
--- b ---
Before: foo👩🏼👨🏻bar
After: foobar

View file

@ -1,19 +1,25 @@
<!DOCTYPE html> <!DOCTYPE html>
<script src="../include.js"></script> <script src="../include.js"></script>
<div contenteditable="true">foobar</div> <div id="a" contenteditable="true">foobar</div>
<div id="b" contenteditable="true">foo👩🏼👨🏻bar</div>
<script> <script>
test(() => { test(() => {
var divElm = document.querySelector('div'); const testDelete = function (divId, position) {
println(`Before: ${divElm.textContent}`); println(`--- ${divId} ---`);
const divElm = document.querySelector(`div#${divId}`);
println(`Before: ${divElm.textContent}`);
// Put cursor after 'foo' // Place cursor
var range = document.createRange(); const node = divElm.childNodes[0];
range.setStart(divElm.childNodes[0], 3); getSelection().setBaseAndExtent(node, position, node, position);
getSelection().addRange(range);
// Press backspace // Press backspace
document.execCommand('delete'); document.execCommand("delete");
println(`After: ${divElm.textContent}`); println(`After: ${divElm.textContent}`);
};
testDelete("a", 3);
testDelete("b", 15);
}); });
</script> </script>

View file

@ -7,6 +7,7 @@
<div id="e" contenteditable="true">a&nbsp;&nbsp;&nbsp;&nbsp;b</div> <div id="e" contenteditable="true">a&nbsp;&nbsp;&nbsp;&nbsp;b</div>
<div id="f" contenteditable="true">a&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;b</div> <div id="f" contenteditable="true">a&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;b</div>
<div id="g" contenteditable="true">&nbsp;&nbsp;b</div> <div id="g" contenteditable="true">&nbsp;&nbsp;b</div>
<div id="h" contenteditable="true">foo👩🏼👨🏻bar</div>
<script> <script>
test(() => { test(() => {
const testForwardDelete = function(divId, position) { const testForwardDelete = function(divId, position) {
@ -31,5 +32,6 @@
testForwardDelete('e', 1); testForwardDelete('e', 1);
testForwardDelete('f', 1); testForwardDelete('f', 1);
testForwardDelete('g', 0); testForwardDelete('g', 0);
testForwardDelete('h', 3);
}); });
</script> </script>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<script src="include.js"></script>
<input id="a" value="foo👩🏼👨🏻bar" />
<input id="b" value="foo👩🏼👨🏻bar" />
<script>
test(() => {
const testDelete = function (id, position, key) {
println(`--- ${id} ---`);
const input = document.querySelector(`input#${id}`);
println(`Before: ${input.value}`);
// Place cursor
input.setSelectionRange(position, position);
// Press backspace
internals.sendKey(input, key);
println(`After: ${input.value}`);
};
testDelete("a", 15, "Backspace");
testDelete("b", 3, "Delete");
});
</script>