LibWeb: Merge nested editing hosts
Some checks are pending
CI / macOS, arm64, Sanitizer, Clang (push) Waiting to run
CI / Linux, x86_64, Fuzzers, Clang (push) Waiting to run
CI / Linux, x86_64, Sanitizer, GNU (push) Waiting to run
CI / Linux, x86_64, Sanitizer, Clang (push) Waiting to run
Package the js repl as a binary artifact / Linux, arm64 (push) Waiting to run
Package the js repl as a binary artifact / macOS, arm64 (push) Waiting to run
Package the js repl as a binary artifact / Linux, x86_64 (push) Waiting to run
Run test262 and test-wasm / run_and_update_results (push) Waiting to run
Lint Code / lint (push) Waiting to run
Label PRs with merge conflicts / auto-labeler (push) Waiting to run
Push notes / build (push) Waiting to run

If a node with `contenteditable=true/plaintextonly` is the child of an
editable node or an editing host, we should make it editable instead of
an editing host. This effectively merges nested editing hosts together,
which is how other browsers deal with this as well.

Gains us 5 WPT subtest passes in `editing`.
This commit is contained in:
Jelle Raaijmakers 2025-09-03 16:53:18 +02:00 committed by Jelle Raaijmakers
commit 8986e1f1ec
Notes: github-actions[bot] 2025-09-03 22:25:59 +00:00
6 changed files with 59 additions and 23 deletions

View file

@ -1573,9 +1573,14 @@ bool Node::is_editing_host() const
// An editing host is either an HTML element with its contenteditable attribute in the true state or
// plaintext-only state,
// AD-HOC: Only return true here if this node is not the child of another editable node or an editing host,
// effectively merging this potential editing host with its editing host ancestor. This causes a call to
// `::editing_host()` to automatically traverse to the top-most editing host.
auto state = html_element->content_editable_state();
if (state == HTML::ContentEditableState::True || state == HTML::ContentEditableState::PlaintextOnly)
if ((state == HTML::ContentEditableState::True || state == HTML::ContentEditableState::PlaintextOnly)
&& (!parent() || !parent()->is_editable_or_editing_host())) {
return true;
}
// or a child HTML element of a Document whose design mode enabled is true.
return is<Document>(parent()) && as<Document>(*parent()).design_mode_enabled_state();

View file

@ -550,8 +550,6 @@ void Selection::set_range(GC::Ptr<DOM::Range> range)
// AD-HOC: Focus editing host if the previous selection was outside of it. There seems to be no spec for this.
if (range && range->start_container()->is_editable_or_editing_host()) {
GC::Ref new_editing_host = *range->start_container()->editing_host();
while (new_editing_host->parent() && new_editing_host->parent()->is_editing_host())
new_editing_host = *new_editing_host->parent();
if (document()->focused_area() != new_editing_host) {
// FIXME: Determine and propagate the right focus trigger.
HTML::run_focusing_steps(new_editing_host, nullptr, HTML::FocusTrigger::Other);

View file

@ -4,3 +4,6 @@ After: fobar
--- b ---
Before: foo👩🏼👨🏻bar
After: foobar
--- c ---
Before: foo<div contenteditable="">bar</div>
After: foobar

View file

@ -1,2 +1,8 @@
Before: <ul><li>foobar</li></ul>
After: <ul><li>foo</li><li>bar</li></ul>
Before: foo <div contenteditable="">bar</div>
After: foo <div contenteditable=""><div>bar</div><div><br></div></div>
Before: foo <div contenteditable="">bar</div>
After: foo <div contenteditable="">bar</div><div contenteditable=""><br></div>
Before: foo <span><div contenteditable="">bar</div></span>
After: foo <span><div contenteditable="">bar</div><div contenteditable=""><br></div></span>

View file

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

View file

@ -1,19 +1,41 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<div contenteditable="true"><ul><li>foobar</li></ul></div>
<div id="a" contenteditable><ul><li>foobar</li></ul></div>
<div id="b">foo <div contenteditable>bar</div></div>
<div id="c" contenteditable>foo <div contenteditable>bar</div></div>
<div id="d" contenteditable>foo <span><div contenteditable>bar</div></span></div>
<script>
test(() => {
var divElm = document.querySelector('div');
println(`Before: ${divElm.innerHTML}`);
// Put cursor after 'foo'
var range = document.createRange();
range.setStart(divElm.firstChild.firstChild.firstChild, 3);
getSelection().addRange(range);
// Press return
// a: Cursor after 'foo', should create a new <li>
const aElm = document.querySelector('#a');
println(`Before: ${aElm.innerHTML}`);
const aAnchor = aElm.firstChild.firstChild.firstChild;
document.getSelection().setBaseAndExtent(aAnchor, 3, aAnchor, 3);
document.execCommand('insertParagraph');
println(`After: ${aElm.innerHTML}`);
println(`After: ${divElm.innerHTML}`);
// b: Cursor after 'bar', should create two new containers inside the inner <div contenteditable>
const bElm = document.querySelector('#b');
println(`Before: ${bElm.innerHTML}`);
const bAnchor = bElm.childNodes[1].firstChild;
document.getSelection().setBaseAndExtent(bAnchor, 3, bAnchor, 3);
document.execCommand('insertParagraph');
println(`After: ${bElm.innerHTML}`);
// c: Cursor after 'bar', should replicate the inner <div contenteditable> as a container
const cElm = document.querySelector('#c');
println(`Before: ${cElm.innerHTML}`);
const cAnchor = cElm.childNodes[1].firstChild;
document.getSelection().setBaseAndExtent(cAnchor, 3, cAnchor, 3);
document.execCommand('insertParagraph');
println(`After: ${cElm.innerHTML}`);
// d: Cursor after 'bar', should replicate the inner <div contenteditable> as a container
const dElm = document.querySelector('#d');
println(`Before: ${dElm.innerHTML}`);
const dAnchor = dElm.childNodes[1].firstChild.firstChild;
document.getSelection().setBaseAndExtent(dAnchor, 3, dAnchor, 3);
document.execCommand('insertParagraph');
println(`After: ${dElm.innerHTML}`);
});
</script>