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 // An editing host is either an HTML element with its contenteditable attribute in the true state or
// plaintext-only state, // 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(); 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; return true;
}
// or a child HTML element of a Document whose design mode enabled is 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(); 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. // 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()) { if (range && range->start_container()->is_editable_or_editing_host()) {
GC::Ref new_editing_host = *range->start_container()->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) { if (document()->focused_area() != new_editing_host) {
// FIXME: Determine and propagate the right focus trigger. // FIXME: Determine and propagate the right focus trigger.
HTML::run_focusing_steps(new_editing_host, nullptr, HTML::FocusTrigger::Other); HTML::run_focusing_steps(new_editing_host, nullptr, HTML::FocusTrigger::Other);

View file

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

View file

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

View file

@ -1,19 +1,41 @@
<!DOCTYPE html> <!DOCTYPE html>
<script src="../include.js"></script> <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> <script>
test(() => { test(() => {
var divElm = document.querySelector('div'); // a: Cursor after 'foo', should create a new <li>
println(`Before: ${divElm.innerHTML}`); const aElm = document.querySelector('#a');
println(`Before: ${aElm.innerHTML}`);
// Put cursor after 'foo' const aAnchor = aElm.firstChild.firstChild.firstChild;
var range = document.createRange(); document.getSelection().setBaseAndExtent(aAnchor, 3, aAnchor, 3);
range.setStart(divElm.firstChild.firstChild.firstChild, 3);
getSelection().addRange(range);
// Press return
document.execCommand('insertParagraph'); 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> </script>