From 4f76cec0965fe77adeba14d82ae2315179964a0a Mon Sep 17 00:00:00 2001 From: Jelle Raaijmakers Date: Tue, 3 Dec 2024 16:29:21 +0100 Subject: [PATCH] LibWeb: Implement document.execCommand("insertParagraph") --- Libraries/LibWeb/Editing/Commands.cpp | 329 ++++++++++++++++++ Libraries/LibWeb/Editing/Commands.h | 1 + .../LibWeb/Editing/Internal/Algorithms.cpp | 319 ++++++++++++++++- .../LibWeb/Editing/Internal/Algorithms.h | 8 + .../Editing/execCommand-insertParagraph.txt | 2 + .../Editing/execCommand-insertParagraph.html | 18 + 6 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/Editing/execCommand-insertParagraph.txt create mode 100644 Tests/LibWeb/Text/input/Editing/execCommand-insertParagraph.html diff --git a/Libraries/LibWeb/Editing/Commands.cpp b/Libraries/LibWeb/Editing/Commands.cpp index 3e3eb9bdf6d..a5a93daac51 100644 --- a/Libraries/LibWeb/Editing/Commands.cpp +++ b/Libraries/LibWeb/Editing/Commands.cpp @@ -4,7 +4,9 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include +#include #include #include #include @@ -366,6 +368,332 @@ bool command_delete_action(DOM::Document& document, String const&) return true; } +// https://w3c.github.io/editing/docs/execCommand/#the-insertparagraph-command +bool command_insert_paragraph_action(DOM::Document& document, String const&) +{ + // 1. Delete the selection. + auto& selection = *document.get_selection(); + delete_the_selection(selection); + + // 2. If the active range's start node is neither editable nor an editing host, return true. + auto& active_range = *selection.range(); + GC::Ptr node = active_range.start_container(); + if (!node->is_editable() && !is_editing_host(*node)) + return true; + + // 3. Let node and offset be the active range's start node and offset. + // NOTE: node is set in step 2 + auto offset = active_range.start_offset(); + + // 4. If node is a Text node, and offset is neither 0 nor the length of node, call splitText(offset) on node. + if (is(*node) && offset != 0 && offset != node->length()) + MUST(static_cast(*node).split_text(offset)); + + // 5. If node is a Text node and offset is its length, set offset to one plus the index of node, then set node to + // its parent. + if (is(*node) && offset == node->length()) { + offset = node->index() + 1; + node = node->parent(); + } + + // 6. If node is a Text or Comment node, set offset to the index of node, then set node to its parent. + if (is(*node) || is(*node)) { + offset = node->index(); + node = node->parent(); + } + + // 7. Call collapse(node, offset) on the context object's selection. + MUST(selection.collapse(node, offset)); + + // 8. Let container equal node. + auto container = node; + + // 9. While container is not a single-line container, and container's parent is editable and in the same editing + // host as node, set container to its parent. + while (!is_single_line_container(*container)) { + auto container_parent = container->parent(); + if (!container_parent->is_editable() || !is_in_same_editing_host(*node, *container_parent)) + break; + container = container_parent; + } + + // 10. If container is an editable single-line container in the same editing host as node, and its local name is "p" + // or "div": + if (container->is_editable() && is_single_line_container(*container) && is_in_same_editing_host(*container, *node) + && is(*container) + && static_cast(*container).local_name().is_one_of(HTML::TagNames::p, HTML::TagNames::div)) { + // 1. Let outer container equal container. + auto outer_container = container; + + // 2. While outer container is not a dd or dt or li, and outer container's parent is editable, set outer + // container to its parent. + auto is_li_dt_or_dd = [](DOM::Element const& node) { + return node.local_name().is_one_of(HTML::TagNames::li, HTML::TagNames::dt, HTML::TagNames::dd); + }; + while (!is(*outer_container) || !is_li_dt_or_dd(static_cast(*outer_container))) { + auto outer_container_parent = outer_container->parent(); + if (!outer_container_parent->is_editable()) + break; + outer_container = outer_container_parent; + } + + // 3. If outer container is a dd or dt or li, set container to outer container. + if (is(*outer_container) && is_li_dt_or_dd(static_cast(*outer_container))) + container = outer_container; + } + + // 11. If container is not editable or not in the same editing host as node or is not a single-line container: + if (!container->is_editable() || !is_in_same_editing_host(*container, *node) || !is_single_line_container(*container)) { + // 1. Let tag be the default single-line container name. + auto tag = document.default_single_line_container_name(); + + // 2. Block-extend the active range, and let new range be the result. + auto new_range = block_extend_a_range(active_range); + + // 3. Let node list be a list of nodes, initially empty. + Vector> node_list; + + // 4. Append to node list the first node in tree order that is contained in new range and is an allowed child of + // "p", if any. + new_range->start_container()->for_each_in_inclusive_subtree([&](DOM::Node& node) { + if (is_allowed_child_of_node(GC::Ref { node }, HTML::TagNames::p) && new_range->contains_node(node)) { + node_list.append(node); + return TraversalDecision::Break; + } + return TraversalDecision::Continue; + }); + + // 5. If node list is empty: + if (node_list.is_empty()) { + // 1. If tag is not an allowed child of the active range's start node, return true. + if (!is_allowed_child_of_node(tag, active_range.start_container())) + return true; + + // 2. Set container to the result of calling createElement(tag) on the context object. + container = MUST(DOM::create_element(document, tag, Namespace::HTML)); + + // 3. Call insertNode(container) on the active range. + MUST(active_range.insert_node(*container)); + + // 4. Call createElement("br") on the context object, and append the result as the last child of container. + MUST(container->append_child(MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)))); + + // 5. Call collapse(container, 0) on the context object's selection. + MUST(selection.collapse(container, 0)); + + // 6. Return true. + return true; + } + + // 6. While the nextSibling of the last member of node list is not null and is an allowed child of "p", append + // it to node list. + auto next_sibling = node_list.last()->next_sibling(); + while (next_sibling && is_allowed_child_of_node(GC::Ref { *next_sibling }, HTML::TagNames::p)) { + node_list.append(*next_sibling); + next_sibling = next_sibling->next_sibling(); + } + + // 7. Wrap node list, with sibling criteria returning false and new parent instructions returning the result of + // calling createElement(tag) on the context object. Set container to the result. + wrap( + node_list, + [](auto) { return false; }, + [&] { return MUST(DOM::create_element(document, tag, Namespace::HTML)); }); + } + + // 12. If container's local name is "address", "listing", or "pre": + if (is(*container) + && static_cast(*container) + .local_name() + .is_one_of(HTML::TagNames::address, HTML::TagNames::listing, HTML::TagNames::pre)) { + // 1. Let br be the result of calling createElement("br") on the context object. + auto br = MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)); + + // 2. Call insertNode(br) on the active range. + MUST(active_range.insert_node(br)); + + // 3. Call collapse(node, offset + 1) on the context object's selection. + MUST(selection.collapse(node, offset + 1)); + + // 4. If br is the last descendant of container, let br be the result of calling createElement("br") on the + // context object, then call insertNode(br) on the active range. + GC::Ptr last_descendant = container->last_child(); + while (last_descendant->has_children()) + last_descendant = last_descendant->last_child(); + if (br == last_descendant) { + br = MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)); + MUST(active_range.insert_node(br)); + } + + // 5. Return true. + return true; + } + + // 13. If container's local name is "li", "dt", or "dd"; and either it has no children or it has a single child and + // that child is a br: + if (is(*container) + && static_cast(*container).local_name().is_one_of(HTML::TagNames::li, HTML::TagNames::dt, HTML::TagNames::dd) + && (!container->has_children() || (container->child_count() == 1 && is(container->first_child())))) { + // 1. Split the parent of the one-node list consisting of container. + split_the_parent_of_nodes({ *container }); + + // 2. If container has no children, call createElement("br") on the context object and append the result as the + // last child of container. + if (!container->has_children()) + MUST(container->append_child(MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)))); + + // 3. If container is a dd or dt, and it is not an allowed child of any of its ancestors in the same editing + // host, set the tag name of container to the default single-line container name and let container be the + // result. + if (static_cast(*container).local_name().is_one_of(HTML::TagNames::dd, HTML::TagNames::dt)) { + bool allowed_child_of_any_ancestor = false; + GC::Ptr ancestor = container->parent(); + do { + if (is_allowed_child_of_node(GC::Ref { *container }, GC::Ref { *ancestor }) + && is_in_same_editing_host(*container, *ancestor)) { + allowed_child_of_any_ancestor = true; + break; + } + ancestor = ancestor->parent(); + } while (ancestor); + if (!allowed_child_of_any_ancestor) + container = set_the_tag_name(static_cast(*container), document.default_single_line_container_name()); + } + + // 4. Fix disallowed ancestors of container. + fix_disallowed_ancestors_of_node(*container); + + // 5. Return true. + return true; + } + + // 14. Let new line range be a new range whose start is the same as the active range's, and whose end is (container, + // length of container). + auto new_line_range = DOM::Range::create(active_range.start_container(), active_range.start_offset(), *container, container->length()); + + // 15. While new line range's start offset is zero and its start node is not a prohibited paragraph child, set its + // start to (parent of start node, index of start node). + GC::Ptr start_container = new_line_range->start_container(); + while (start_container->parent() && new_line_range->start_offset() == 0 && !is_prohibited_paragraph_child(*start_container)) { + MUST(new_line_range->set_start(*start_container->parent(), start_container->index())); + start_container = start_container->parent(); + } + + // 16. While new line range's start offset is the length of its start node and its start node is not a prohibited + // paragraph child, set its start to (parent of start node, 1 + index of start node). + start_container = new_line_range->start_container(); + while (start_container->parent() && new_line_range->start_offset() == start_container->length() + && !is_prohibited_paragraph_child(*start_container)) { + MUST(new_line_range->set_start(*start_container->parent(), start_container->index() + 1)); + start_container = start_container->parent(); + } + + // 17. Let end of line be true if new line range contains either nothing or a single br, and false otherwise. + auto end_of_line = new_line_range->collapsed() + || ((new_line_range->start_container() == new_line_range->end_container() && new_line_range->start_offset() == new_line_range->end_offset() - 1) + && is(*new_line_range->start_container())); + + VERIFY(is(*container)); + auto& container_element = static_cast(*container); + auto new_container_name = [&] -> FlyString { + // 18. If the local name of container is "h1", "h2", "h3", "h4", "h5", or "h6", and end of line is true, let new + // container name be the default single-line container name. + if (end_of_line && is_heading(container_element.local_name())) + return document.default_single_line_container_name(); + + // 19. Otherwise, if the local name of container is "dt" and end of line is true, let new container name be "dd". + if (container_element.local_name() == HTML::TagNames::dt && end_of_line) + return HTML::TagNames::dd; + + // 20. Otherwise, if the local name of container is "dd" and end of line is true, let new container name be "dt". + if (container_element.local_name() == HTML::TagNames::dd && end_of_line) + return HTML::TagNames::dt; + + // 21. Otherwise, let new container name be the local name of container. + return container_element.local_name(); + }(); + + // 22. Let new container be the result of calling createElement(new container name) on the context object. + GC::Ptr new_container = MUST(DOM::create_element(document, new_container_name, Namespace::HTML)); + + // 23. Copy all attributes of container to new container. + container_element.for_each_attribute([&new_container](FlyString const& name, String const& value) { + MUST(new_container->set_attribute(name, value)); + }); + + // 24. If new container has an id attribute, unset it. + if (new_container->has_attribute(HTML::AttributeNames::id)) + new_container->remove_attribute(HTML::AttributeNames::id); + + // 25. Insert new container into the parent of container immediately after container. + container->parent()->insert_before(*new_container, container->next_sibling()); + + // 26. Let contained nodes be all nodes contained in new line range. + // FIXME: this is probably wildly inefficient + Vector> contained_nodes; + auto common_ancestor = new_line_range->common_ancestor_container(); + common_ancestor->for_each_in_subtree([&](GC::Ref child_node) { + if (new_line_range->contains_node(child_node)) + contained_nodes.append(child_node); + return TraversalDecision::Continue; + }); + + // 27. Let frag be the result of calling extractContents() on new line range. + auto frag = MUST(new_line_range->extract_contents()); + + // 28. Unset the id attribute (if any) of each Element descendant of frag that is not in contained nodes. + frag->for_each_in_subtree_of_type([&contained_nodes](GC::Ref descendant) { + if (!contained_nodes.contains_slow(descendant)) + descendant->remove_attribute(HTML::AttributeNames::id); + return TraversalDecision::Continue; + }); + + // 29. Call appendChild(frag) on new container. + MUST(new_container->append_child(frag)); + + // 30. While container's lastChild is a prohibited paragraph child, set container to its lastChild. + while (container->last_child() && is_prohibited_paragraph_child(*container->last_child())) + container = container->last_child(); + + // 31. While new container's lastChild is a prohibited paragraph child, set new container to its lastChild. + while (new_container->last_child() && is_prohibited_paragraph_child(*new_container->last_child())) { + // NOTE: is_prohibited_paragraph_child() ensures that last_child() is an HTML::HTMLElement + new_container = static_cast(new_container->last_child()); + } + + // 32. If container has no visible children, call createElement("br") on the context object, and append the result + // as the last child of container. + bool has_visible_child = false; + container->for_each_child([&has_visible_child](GC::Ref child) { + if (is_visible_node(child)) { + has_visible_child = true; + return IterationDecision::Break; + } + return IterationDecision::Continue; + }); + if (!has_visible_child) + MUST(container->append_child(MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)))); + + // 33. If new container has no visible children, call createElement("br") on the context object, and append the + // result as the last child of new container. + has_visible_child = false; + new_container->for_each_child([&has_visible_child](GC::Ref child) { + if (is_visible_node(child)) { + has_visible_child = true; + return IterationDecision::Break; + } + return IterationDecision::Continue; + }); + if (!has_visible_child) + MUST(new_container->append_child(MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)))); + + // 34. Call collapse(new container, 0) on the context object's selection. + MUST(document.get_selection()->collapse(new_container, 0)); + + // 35. Return true + return true; +} + // https://w3c.github.io/editing/docs/execCommand/#the-stylewithcss-command bool command_style_with_css_action(DOM::Document& document, String const& value) { @@ -387,6 +715,7 @@ bool command_style_with_css_state(DOM::Document const& document) static Array const commands { CommandDefinition { CommandNames::delete_, command_delete_action, {}, {}, {} }, CommandDefinition { CommandNames::defaultParagraphSeparator, command_default_paragraph_separator_action, {}, {}, command_default_paragraph_separator_value }, + CommandDefinition { CommandNames::insertParagraph, command_insert_paragraph_action, {}, {}, {} }, CommandDefinition { CommandNames::styleWithCSS, command_style_with_css_action, {}, command_style_with_css_state, {} }, }; diff --git a/Libraries/LibWeb/Editing/Commands.h b/Libraries/LibWeb/Editing/Commands.h index 90da3da0826..6eb3f8385d3 100644 --- a/Libraries/LibWeb/Editing/Commands.h +++ b/Libraries/LibWeb/Editing/Commands.h @@ -24,6 +24,7 @@ Optional find_command_definition(FlyString const&); bool command_default_paragraph_separator_action(DOM::Document&, String const&); String command_default_paragraph_separator_value(DOM::Document const&); bool command_delete_action(DOM::Document&, String const&); +bool command_insert_paragraph_action(DOM::Document&, String const&); bool command_style_with_css_action(DOM::Document&, String const&); bool command_style_with_css_state(DOM::Document const&); diff --git a/Libraries/LibWeb/Editing/Internal/Algorithms.cpp b/Libraries/LibWeb/Editing/Internal/Algorithms.cpp index 5e624c89513..187e480d33b 100644 --- a/Libraries/LibWeb/Editing/Internal/Algorithms.cpp +++ b/Libraries/LibWeb/Editing/Internal/Algorithms.cpp @@ -24,6 +24,98 @@ namespace Web::Editing { +// https://w3c.github.io/editing/docs/execCommand/#block-extend +GC::Ref block_extend_a_range(DOM::Range& range) +{ + // 1. Let start node, start offset, end node, and end offset be the start and end nodes and offsets of range. + GC::Ptr start_node = range.start_container(); + auto start_offset = range.start_offset(); + GC::Ptr end_node = range.end_container(); + auto end_offset = range.end_offset(); + + // 2. If some inclusive ancestor of start node is an li, set start offset to the index of the last such li in tree + // order, and set start node to that li's parent. + auto ancestor = start_node; + do { + if (is(*ancestor)) { + start_offset = ancestor->index(); + start_node = ancestor->parent(); + break; + } + ancestor = ancestor->parent(); + } while (ancestor); + + // 3. If (start node, start offset) is not a block start point, repeat the following steps: + if (!is_block_start_point(*start_node, start_offset)) { + do { + // 1. If start offset is zero, set it to start node's index, then set start node to its parent. + if (start_offset == 0) { + start_offset = start_node->index(); + start_node = start_node->parent(); + } + + // 2. Otherwise, subtract one from start offset. + else { + --start_offset; + } + + // 3. If (start node, start offset) is a block boundary point, break from this loop. + } while (!is_block_boundary_point(*start_node, start_offset)); + } + + // 4. While start offset is zero and start node's parent is not null, set start offset to start node's index, then + // set start node to its parent. + while (start_offset == 0 && start_node->parent()) { + start_offset = start_node->index(); + start_node = start_node->parent(); + } + + // 5. If some inclusive ancestor of end node is an li, set end offset to one plus the index of the last such li in + // tree order, and set end node to that li's parent. + ancestor = end_node; + do { + if (is(*ancestor)) { + end_offset = ancestor->index() + 1; + end_node = ancestor->parent(); + break; + } + ancestor = ancestor->parent(); + } while (ancestor); + + // 6. If (end node, end offset) is not a block end point, repeat the following steps: + if (!is_block_end_point(*end_node, end_offset)) { + do { + // 1. If end offset is end node's length, set it to one plus end node's index, then set end node to its + // parent. + if (end_offset == end_node->length()) { + end_offset = end_node->index() + 1; + end_node = end_node->parent(); + } + + // 2. Otherwise, add one to end offset. + else { + ++end_offset; + } + + // 3. If (end node, end offset) is a block boundary point, break from this loop. + } while (!is_block_boundary_point(*end_node, end_offset)); + } + + // 7. While end offset is end node's length and end node's parent is not null, set end offset to one plus end node's + // index, then set end node to its parent. + while (end_offset == end_node->length() && end_node->parent()) { + end_offset = end_node->index() + 1; + end_node = end_node->parent(); + } + + // 8. Let new range be a new range whose start and end nodes and offsets are start node, start offset, end node, and + // end offset. + auto new_range = DOM::Range::create(*start_node, start_offset, *end_node, end_offset); + + // 9. Return new range. + return new_range; +} + // https://w3c.github.io/editing/docs/execCommand/#canonical-space-sequence String canonical_space_sequence(u32 length, bool non_breaking_start, bool non_breaking_end) { @@ -521,15 +613,6 @@ bool is_allowed_child_of_node(Variant, FlyString> child, Vari auto child_local_name = child.get(); // 6. If parent is an HTML element: - auto is_heading = [](FlyString const& local_name) { - return local_name.is_one_of( - HTML::TagNames::h1, - HTML::TagNames::h2, - HTML::TagNames::h3, - HTML::TagNames::h4, - HTML::TagNames::h5, - HTML::TagNames::h6); - }; if (is(parent_node.ptr())) { auto& parent_html_element = static_cast(*parent.get>()); @@ -932,6 +1015,19 @@ bool is_name_of_an_element_with_inline_contents(FlyString const& local_name) HTML::TagNames::tt); } +// https://w3c.github.io/editing/docs/execCommand/#non-list-single-line-container +bool is_non_list_single_line_container(GC::Ref node) +{ + // A non-list single-line container is an HTML element with local name "address", "divis_", "h1", "h2", "h3", "h4", + // "h5", "h6", "listing", "p", "pre", or "xmp". + if (!is(*node)) + return false; + auto& local_name = static_cast(*node).local_name(); + return is_heading(local_name) + || local_name.is_one_of(HTML::TagNames::address, HTML::TagNames::div, HTML::TagNames::listing, + HTML::TagNames::p, HTML::TagNames::pre, HTML::TagNames::xmp); +} + // https://w3c.github.io/editing/docs/execCommand/#prohibited-paragraph-child bool is_prohibited_paragraph_child(GC::Ref node) { @@ -996,6 +1092,19 @@ bool is_prohibited_paragraph_child_name(FlyString const& local_name) HTML::TagNames::xmp); } +// https://w3c.github.io/editing/docs/execCommand/#single-line-container +bool is_single_line_container(GC::Ref node) +{ + // A single-line container is either a non-list single-line container, or an HTML element with local name "li", + // "dt", or "dd". + if (is_non_list_single_line_container(node)) + return true; + if (!is(*node)) + return false; + auto& html_element = static_cast(*node); + return html_element.local_name().is_one_of(HTML::TagNames::li, HTML::TagNames::dt, HTML::TagNames::dd); +} + // https://w3c.github.io/editing/docs/execCommand/#visible bool is_visible_node(GC::Ref node) { @@ -1571,4 +1680,196 @@ void split_the_parent_of_nodes(Vector> const& nodes) remove_extraneous_line_breaks_at_the_end_of_node(*last_node->parent()); } +// https://w3c.github.io/editing/docs/execCommand/#wrap +GC::Ptr wrap( + Vector> node_list, + Function)> sibling_criteria, + Function()> new_parent_instructions) +{ + VERIFY(!node_list.is_empty()); + + // If not provided, sibling criteria returns false and new parent instructions returns null. + if (!sibling_criteria) + sibling_criteria = [](auto) { return false; }; + if (!new_parent_instructions) + new_parent_instructions = [] { return nullptr; }; + + // 1. If every member of node list is invisible, and none is a br, return null and abort these steps. + auto any_node_visible_or_br = false; + for (auto& node : node_list) { + if (is_visible_node(node) || is(*node)) { + any_node_visible_or_br = true; + break; + } + } + if (!any_node_visible_or_br) + return {}; + + // 2. If node list's first member's parent is null, return null and abort these steps. + if (!node_list.first()->parent()) + return {}; + + // 3. If node list's last member is an inline node that's not a br, and node list's last member's nextSibling is a + // br, append that br to node list. + auto last_member = node_list.last(); + if (is_inline_node(last_member) && !is(*last_member) && is(last_member->next_sibling())) + node_list.append(*last_member->next_sibling()); + + // 4. While node list's first member's previousSibling is invisible, prepend it to node list. + while (node_list.first()->previous_sibling() && is_invisible_node(*node_list.first()->previous_sibling())) + node_list.prepend(*node_list.first()->previous_sibling()); + + // 5. While node list's last member's nextSibling is invisible, append it to node list. + while (node_list.last()->next_sibling() && is_invisible_node(*node_list.last()->next_sibling())) + node_list.append(*node_list.last()->next_sibling()); + + auto new_parent = [&]() -> GC::Ptr { + // 6. If the previousSibling of the first member of node list is editable and running sibling criteria on it returns + // true, let new parent be the previousSibling of the first member of node list. + GC::Ptr previous_sibling = node_list.first()->previous_sibling(); + if (previous_sibling && previous_sibling->is_editable() && sibling_criteria(*previous_sibling)) + return previous_sibling; + + // 7. Otherwise, if the nextSibling of the last member of node list is editable and running sibling criteria on it + // returns true, let new parent be the nextSibling of the last member of node list. + GC::Ptr next_sibling = node_list.last()->next_sibling(); + if (next_sibling && next_sibling->is_editable() && sibling_criteria(*next_sibling)) + return next_sibling; + + // 8. Otherwise, run new parent instructions, and let new parent be the result. + return new_parent_instructions(); + }(); + + // 9. If new parent is null, abort these steps and return null. + if (!new_parent) + return {}; + + // 10. If new parent's parent is null: + if (!new_parent->parent()) { + // 1. Insert new parent into the parent of the first member of node list immediately before the first member of + // node list. + auto first_member = node_list.first(); + first_member->parent()->insert_before(*new_parent, first_member); + + // FIXME: 2. If any range has a boundary point with node equal to the parent of new parent and offset equal to the + // index of new parent, add one to that boundary point's offset. + } + + // 11. Let original parent be the parent of the first member of node list. + auto const original_parent = GC::Ptr { node_list.first()->parent() }; + + // 12. If new parent is before the first member of node list in tree order: + if (new_parent->is_before(node_list.first())) { + // 1. If new parent is not an inline node, but the last visible child of new parent and the first visible member + // of node list are both inline nodes, and the last child of new parent is not a br, call createElement("br") + // on the ownerDocument of new parent and append the result as the last child of new parent. + if (!is_inline_node(*new_parent)) { + auto last_visible_child = [&] -> GC::Ref { + GC::Ptr child = new_parent->last_child(); + do { + if (is_visible_node(*child)) + return *child; + child = child->previous_sibling(); + } while (child); + VERIFY_NOT_REACHED(); + }(); + auto first_visible_member = [&] -> GC::Ref { + for (auto& member : node_list) { + if (is_visible_node(member)) + return member; + } + VERIFY_NOT_REACHED(); + }(); + if (is_inline_node(last_visible_child) && is_inline_node(first_visible_member) + && !is(new_parent->last_child())) { + auto br_element = MUST(DOM::create_element(*new_parent->owner_document(), HTML::TagNames::br, Namespace::HTML)); + MUST(new_parent->append_child(br_element)); + } + } + + // 2. For each node in node list, append node as the last child of new parent, preserving ranges. + auto new_position = new_parent->child_count(); + for (auto& node : node_list) + move_node_preserving_ranges(node, *new_parent, new_position++); + } + + // 13. Otherwise: + else { + // 1. If new parent is not an inline node, but the first visible child of new parent and the last visible member + // of node list are both inline nodes, and the last member of node list is not a br, call createElement("br") + // on the ownerDocument of new parent and insert the result as the first child of new parent. + if (!is_inline_node(*new_parent)) { + auto first_visible_child = [&] -> GC::Ref { + GC::Ptr child = new_parent->first_child(); + do { + if (is_visible_node(*child)) + return *child; + child = child->next_sibling(); + } while (child); + VERIFY_NOT_REACHED(); + }(); + auto last_visible_member = [&] -> GC::Ref { + for (auto& member : node_list.in_reverse()) { + if (is_visible_node(member)) + return member; + } + VERIFY_NOT_REACHED(); + }(); + if (is_inline_node(first_visible_child) && is_inline_node(last_visible_member) + && !is(*node_list.last())) { + auto br_element = MUST(DOM::create_element(*new_parent->owner_document(), HTML::TagNames::br, Namespace::HTML)); + new_parent->insert_before(br_element, new_parent->first_child()); + } + } + + // 2. For each node in node list, in reverse order, insert node as the first child of new parent, preserving + // ranges. + for (auto& node : node_list.in_reverse()) + move_node_preserving_ranges(node, *new_parent, 0); + } + + // 14. If original parent is editable and has no children, remove it from its parent. + if (original_parent->is_editable() && !original_parent->has_children()) + original_parent->remove(); + + // 15. If new parent's nextSibling is editable and running sibling criteria on it returns true: + GC::Ptr next_sibling = new_parent->next_sibling(); + if (next_sibling && next_sibling->is_editable() && sibling_criteria(*next_sibling)) { + // 1. If new parent is not an inline node, but new parent's last child and new parent's nextSibling's first + // child are both inline nodes, and new parent's last child is not a br, call createElement("br") on the + // ownerDocument of new parent and append the result as the last child of new parent. + if (!is_inline_node(*new_parent) && is_inline_node(*new_parent->last_child()) + && is_inline_node(*next_sibling->first_child()) && !is(new_parent->last_child())) { + auto br_element = MUST(DOM::create_element(*new_parent->owner_document(), HTML::TagNames::br, Namespace::HTML)); + MUST(new_parent->append_child(br_element)); + } + + // 2. While new parent's nextSibling has children, append its first child as the last child of new parent, + // preserving ranges. + auto new_position = new_parent->child_count(); + while (next_sibling->has_children()) + move_node_preserving_ranges(*next_sibling->first_child(), *new_parent, new_position++); + + // 3. Remove new parent's nextSibling from its parent. + next_sibling->remove(); + } + + // 16. Remove extraneous line breaks from new parent. + remove_extraneous_line_breaks_from_a_node(*new_parent); + + // 17. Return new parent. + return new_parent; +} + +bool is_heading(FlyString const& local_name) +{ + return local_name.is_one_of( + HTML::TagNames::h1, + HTML::TagNames::h2, + HTML::TagNames::h3, + HTML::TagNames::h4, + HTML::TagNames::h5, + HTML::TagNames::h6); +} + } diff --git a/Libraries/LibWeb/Editing/Internal/Algorithms.h b/Libraries/LibWeb/Editing/Internal/Algorithms.h index 2f64b0c51de..441a708990d 100644 --- a/Libraries/LibWeb/Editing/Internal/Algorithms.h +++ b/Libraries/LibWeb/Editing/Internal/Algorithms.h @@ -21,6 +21,7 @@ struct RecordedNodeValue { // Below algorithms are specified here: // https://w3c.github.io/editing/docs/execCommand/#assorted-common-algorithms +GC::Ref block_extend_a_range(DOM::Range&); String canonical_space_sequence(u32 length, bool non_breaking_start, bool non_breaking_end); void canonicalize_whitespace(GC::Ref, u32 offset, bool fix_collapsed_space = true); void delete_the_selection(Selection::Selection const&); @@ -40,8 +41,10 @@ bool is_in_same_editing_host(GC::Ref, GC::Ref); bool is_inline_node(GC::Ref); bool is_invisible_node(GC::Ref); bool is_name_of_an_element_with_inline_contents(FlyString const&); +bool is_non_list_single_line_container(GC::Ref); bool is_prohibited_paragraph_child(GC::Ref); bool is_prohibited_paragraph_child_name(FlyString const&); +bool is_single_line_container(GC::Ref); bool is_visible_node(GC::Ref); bool is_whitespace_node(GC::Ref); void move_node_preserving_ranges(GC::Ref, GC::Ref new_parent, u32 new_index); @@ -56,5 +59,10 @@ void restore_the_values_of_nodes(Vector const&); GC::Ref set_the_tag_name(GC::Ref, FlyString const&); Optional specified_command_value(GC::Ref, FlyString const& command); void split_the_parent_of_nodes(Vector> const&); +GC::Ptr wrap(Vector>, Function)> sibling_criteria, Function()> new_parent_instructions); + +// Utility methods: + +bool is_heading(FlyString const&); } diff --git a/Tests/LibWeb/Text/expected/Editing/execCommand-insertParagraph.txt b/Tests/LibWeb/Text/expected/Editing/execCommand-insertParagraph.txt new file mode 100644 index 00000000000..79199075692 --- /dev/null +++ b/Tests/LibWeb/Text/expected/Editing/execCommand-insertParagraph.txt @@ -0,0 +1,2 @@ +Before:
  • foobar
+After:
  • foo
  • bar
diff --git a/Tests/LibWeb/Text/input/Editing/execCommand-insertParagraph.html b/Tests/LibWeb/Text/input/Editing/execCommand-insertParagraph.html new file mode 100644 index 00000000000..cf8a72d0ead --- /dev/null +++ b/Tests/LibWeb/Text/input/Editing/execCommand-insertParagraph.html @@ -0,0 +1,18 @@ + +
  • foobar
+