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
*/
#include <LibUnicode/Segmenter.h>
#include <LibWeb/CSS/StyleValues/KeywordStyleValue.h>
#include <LibWeb/DOM/Comment.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);
}
}
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.
MUST(selection.collapse(node, offset));
// 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.
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:
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.
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
// 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 (direction == DeleteDirection::Backward) {
if (selection_start > 0)
MUST(set_range_text({}, selection_start - 1, selection_end, Bindings::SelectionMode::End));
if (auto offset = text_node->grapheme_segmenter().previous_boundary(m_selection_end); offset.has_value())
selection_start = *offset;
} else {
if (selection_start < text_node->length_in_utf16_code_units())
MUST(set_range_text({}, selection_start, selection_end + 1, Bindings::SelectionMode::End));
if (auto offset = text_node->grapheme_segmenter().next_boundary(m_selection_end); offset.has_value())
selection_end = *offset;
}
return;
}
MUST(set_range_text({}, selection_start, selection_end, Bindings::SelectionMode::End));

View file

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

View file

@ -19,3 +19,6 @@ After: a&nbsp; &nbsp; b
--- g ---
Before: &nbsp;&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>
<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>
test(() => {
var divElm = document.querySelector('div');
println(`Before: ${divElm.textContent}`);
const testDelete = function (divId, position) {
println(`--- ${divId} ---`);
const divElm = document.querySelector(`div#${divId}`);
println(`Before: ${divElm.textContent}`);
// Put cursor after 'foo'
var range = document.createRange();
range.setStart(divElm.childNodes[0], 3);
getSelection().addRange(range);
// Place cursor
const node = divElm.childNodes[0];
getSelection().setBaseAndExtent(node, position, node, position);
// Press backspace
document.execCommand('delete');
// Press backspace
document.execCommand("delete");
println(`After: ${divElm.textContent}`);
println(`After: ${divElm.textContent}`);
};
testDelete("a", 3);
testDelete("b", 15);
});
</script>

View file

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