mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2025-08-26 20:26:53 +00:00
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:
parent
76bab90812
commit
c369f68eff
Notes:
github-actions[bot]
2025-08-14 20:22:53 +00:00
Author: https://github.com/trflynn89
Commit: c369f68eff
Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/5859
Reviewed-by: https://github.com/gmta ✅
8 changed files with 69 additions and 19 deletions
|
@ -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.
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -1,2 +1,6 @@
|
||||||
|
--- a ---
|
||||||
Before: foobar
|
Before: foobar
|
||||||
After: fobar
|
After: fobar
|
||||||
|
--- b ---
|
||||||
|
Before: foo👩🏼❤️👨🏻bar
|
||||||
|
After: foobar
|
||||||
|
|
|
@ -19,3 +19,6 @@ After: a b
|
||||||
--- g ---
|
--- g ---
|
||||||
Before: b
|
Before: b
|
||||||
After: b
|
After: b
|
||||||
|
--- h ---
|
||||||
|
Before: foo👩🏼❤️👨🏻bar
|
||||||
|
After: foobar
|
||||||
|
|
6
Tests/LibWeb/Text/expected/input-delete.txt
Normal file
6
Tests/LibWeb/Text/expected/input-delete.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
--- a ---
|
||||||
|
Before: foo👩🏼❤️👨🏻bar
|
||||||
|
After: foobar
|
||||||
|
--- b ---
|
||||||
|
Before: foo👩🏼❤️👨🏻bar
|
||||||
|
After: foobar
|
|
@ -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(`--- ${divId} ---`);
|
||||||
|
const divElm = document.querySelector(`div#${divId}`);
|
||||||
println(`Before: ${divElm.textContent}`);
|
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>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<div id="e" contenteditable="true">a b</div>
|
<div id="e" contenteditable="true">a b</div>
|
||||||
<div id="f" contenteditable="true">a b</div>
|
<div id="f" contenteditable="true">a b</div>
|
||||||
<div id="g" contenteditable="true"> b</div>
|
<div id="g" contenteditable="true"> 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>
|
||||||
|
|
24
Tests/LibWeb/Text/input/input-delete.html
Normal file
24
Tests/LibWeb/Text/input/input-delete.html
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue