Skip to content

Commit a12d887

Browse files
gmtaawesomekling
authored andcommitted
LibWeb: Implement the "formatBlock" editing command
1 parent e686328 commit a12d887

File tree

6 files changed

+304
-3
lines changed

6 files changed

+304
-3
lines changed

Libraries/LibWeb/Editing/Commands.cpp

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,260 @@ bool command_fore_color_action(DOM::Document& document, String const& value)
615615
return true;
616616
}
617617

618+
// https://w3c.github.io/editing/docs/execCommand/#the-formatblock-command
619+
bool command_format_block_action(DOM::Document& document, String const& value)
620+
{
621+
// 1. If value begins with a "<" character and ends with a ">" character, remove the first and last characters from
622+
// it.
623+
auto resulting_value = value;
624+
if (resulting_value.starts_with_bytes("<"sv) && resulting_value.ends_with_bytes(">"sv))
625+
resulting_value = MUST(resulting_value.substring_from_byte_offset(1, resulting_value.bytes_as_string_view().length() - 2));
626+
627+
// 2. Let value be converted to ASCII lowercase.
628+
resulting_value = resulting_value.to_ascii_lowercase();
629+
630+
// 3. If value is not a formattable block name, return false.
631+
if (!is_formattable_block_name(resulting_value))
632+
return false;
633+
634+
// 4. Block-extend the active range, and let new range be the result.
635+
auto new_range = block_extend_a_range(*active_range(document));
636+
637+
// 5. Let node list be an empty list of nodes.
638+
Vector<GC::Ref<DOM::Node>> node_list;
639+
640+
// 6. For each node node contained in new range, append node to node list if it is editable, the last member of
641+
// original node list (if any) is not an ancestor of node, node is either a non-list single-line container or an
642+
// allowed child of "p" or a dd or dt, and node is not the ancestor of a prohibited paragraph child.
643+
auto is_ancestor_of_prohibited_paragraph_child = [](GC::Ref<DOM::Node> node) {
644+
bool result = false;
645+
node->for_each_in_subtree([&result](GC::Ref<DOM::Node> descendant) {
646+
if (is_prohibited_paragraph_child(descendant)) {
647+
result = true;
648+
return TraversalDecision::Break;
649+
}
650+
return TraversalDecision::Continue;
651+
});
652+
return result;
653+
};
654+
new_range->for_each_contained([&](GC::Ref<DOM::Node> node) {
655+
if (node->is_editable()
656+
&& (node_list.is_empty() || !node_list.last()->is_ancestor_of(node))
657+
&& (is_non_list_single_line_container(node) || is_allowed_child_of_node(node, HTML::TagNames::p)
658+
|| (is<DOM::Element>(*node)
659+
&& static_cast<DOM::Element&>(*node).local_name().is_one_of(HTML::TagNames::dd, HTML::TagNames::dt)))
660+
&& !is_ancestor_of_prohibited_paragraph_child(node)) {
661+
node_list.append(node);
662+
}
663+
return IterationDecision::Continue;
664+
});
665+
666+
// 7. Record the values of node list, and let values be the result.
667+
auto values = record_the_values_of_nodes(node_list);
668+
669+
// 8. For each node in node list, while node is the descendant of an editable HTML element in the same editing host,
670+
// whose local name is a formattable block name, and which is not the ancestor of a prohibited paragraph child,
671+
// split the parent of the one-node list consisting of node.
672+
for (auto node : node_list) {
673+
while (true) {
674+
bool is_matching_descendant = false;
675+
node->for_each_ancestor([&](GC::Ref<DOM::Node> ancestor) {
676+
if (ancestor->is_editable() && is<HTML::HTMLElement>(*ancestor) && is_in_same_editing_host(node, ancestor)
677+
&& is_formattable_block_name(static_cast<DOM::Element&>(*ancestor).local_name())
678+
&& !is_ancestor_of_prohibited_paragraph_child(ancestor)) {
679+
is_matching_descendant = true;
680+
return IterationDecision::Break;
681+
}
682+
return IterationDecision::Continue;
683+
});
684+
if (!is_matching_descendant)
685+
break;
686+
687+
split_the_parent_of_nodes({ node });
688+
}
689+
}
690+
691+
// 9. Restore the values from values.
692+
restore_the_values_of_nodes(values);
693+
694+
// 10. While node list is not empty:
695+
while (!node_list.is_empty()) {
696+
Vector<GC::Ref<DOM::Node>> sublist;
697+
698+
// 1. If the first member of node list is a single-line container:
699+
if (is_single_line_container(node_list.first())) {
700+
// AD-HOC: The spec makes note of single-line containers without children, and how they should probably
701+
// disappear given that Firefox and Opera did this at the time. We're going to follow their lead and
702+
// remove the node if it has no children.
703+
if (!node_list.first()->has_children()) {
704+
node_list.take_first()->remove();
705+
continue;
706+
}
707+
708+
// 1. Let sublist be the children of the first member of node list.
709+
node_list.first()->for_each_child([&sublist](GC::Ref<DOM::Node> child) {
710+
sublist.append(child);
711+
return IterationDecision::Continue;
712+
});
713+
714+
// 2. Record the values of sublist, and let values be the result.
715+
auto values = record_the_values_of_nodes(sublist);
716+
717+
// 3. Remove the first member of node list from its parent, preserving its descendants.
718+
remove_node_preserving_its_descendants(node_list.first());
719+
720+
// 4. Restore the values from values.
721+
restore_the_values_of_nodes(values);
722+
723+
// 5. Remove the first member from node list.
724+
node_list.take_first();
725+
}
726+
727+
// 2. Otherwise:
728+
else {
729+
// 1. Let sublist be an empty list of nodes.
730+
// 2. Remove the first member of node list and append it to sublist.
731+
sublist.append(node_list.take_first());
732+
733+
// 3. While node list is not empty, and the first member of node list is the nextSibling of the last member
734+
// of sublist, and the first member of node list is not a single-line container, and the last member of
735+
// sublist is not a br, remove the first member of node list and append it to sublist.
736+
while (!node_list.is_empty() && node_list.first().ptr() == sublist.last()->next_sibling()
737+
&& !is_single_line_container(node_list.first()) && !is<HTML::HTMLBRElement>(*sublist.last()))
738+
sublist.append(node_list.take_first());
739+
}
740+
741+
// 3. Wrap sublist. If value is "div" or "p", sibling criteria returns false; otherwise it returns true for an
742+
// HTML element with local name value and no attributes, and false otherwise. New parent instructions return
743+
// the result of running createElement(value) on the context object. Then fix disallowed ancestors of the
744+
// result.
745+
auto result = wrap(
746+
sublist,
747+
[&](GC::Ref<DOM::Node> sibling) {
748+
if (resulting_value.is_one_of("div"sv, "p"sv))
749+
return false;
750+
return is<HTML::HTMLElement>(*sibling)
751+
&& static_cast<DOM::Element&>(*sibling).local_name() == resulting_value
752+
&& !static_cast<DOM::Element&>(*sibling).has_attributes();
753+
},
754+
[&] { return MUST(DOM::create_element(document, resulting_value, Namespace::HTML)); });
755+
if (result)
756+
fix_disallowed_ancestors_of_node(*result);
757+
}
758+
759+
// 11. Return true.
760+
return true;
761+
}
762+
763+
// https://w3c.github.io/editing/docs/execCommand/#the-formatblock-command
764+
bool command_format_block_indeterminate(DOM::Document const& document)
765+
{
766+
// 1. If the active range is null, return the empty string.
767+
// AD-HOC: We're returning false instead. See https://github.com/w3c/editing/issues/474
768+
auto range = active_range(document);
769+
if (!range)
770+
return false;
771+
772+
// 2. Block-extend the active range, and let new range be the result.
773+
auto new_range = block_extend_a_range(*range);
774+
775+
// 3. Let node list be all visible editable nodes that are contained in new range and have no children.
776+
Vector<GC::Ref<DOM::Node>> node_list;
777+
new_range->for_each_contained([&](GC::Ref<DOM::Node> node) {
778+
if (is_visible_node(node) && node->is_editable() && !node->has_children())
779+
node_list.append(node);
780+
return IterationDecision::Continue;
781+
});
782+
783+
// 4. If node list is empty, return false.
784+
if (node_list.is_empty())
785+
return false;
786+
787+
// 5. Let type be null.
788+
Optional<FlyString const&> type;
789+
790+
// 6. For each node in node list:
791+
for (auto node : node_list) {
792+
// 1. While node's parent is editable and in the same editing host as node, and node is not an HTML element
793+
// whose local name is a formattable block name, set node to its parent.
794+
while (node->parent() && node->parent()->is_editable() && is_in_same_editing_host(node, *node->parent())
795+
&& !(is<HTML::HTMLElement>(*node) && is_formattable_block_name(static_cast<DOM::Element&>(*node).local_name())))
796+
node = *node->parent();
797+
798+
// 2. Let current type be the empty string.
799+
FlyString current_type;
800+
801+
// 3. If node is an editable HTML element whose local name is a formattable block name, and node is not the
802+
// ancestor of a prohibited paragraph child, set current type to node's local name.
803+
if (node->is_editable() && is<HTML::HTMLElement>(*node)
804+
&& is_formattable_block_name(static_cast<DOM::Element&>(*node).local_name()))
805+
current_type = static_cast<DOM::Element&>(*node).local_name();
806+
807+
// 4. If type is null, set type to current type.
808+
if (!type.has_value()) {
809+
type = current_type;
810+
}
811+
812+
// 5. Otherwise, if type does not equal current type, return true.
813+
else if (type.value() != current_type) {
814+
return true;
815+
}
816+
}
817+
818+
// 7. Return false.
819+
return false;
820+
}
821+
822+
// https://w3c.github.io/editing/docs/execCommand/#the-formatblock-command
823+
String command_format_block_value(DOM::Document const& document)
824+
{
825+
// 1. If the active range is null, return the empty string.
826+
auto range = active_range(document);
827+
if (!range)
828+
return {};
829+
830+
// 2. Block-extend the active range, and let new range be the result.
831+
auto new_range = block_extend_a_range(*range);
832+
833+
// 3. Let node be the first visible editable node that is contained in new range and has no children. If there is no
834+
// such node, return the empty string.
835+
GC::Ptr<DOM::Node> node;
836+
new_range->for_each_contained([&](GC::Ref<DOM::Node> contained_node) {
837+
if (is_visible_node(contained_node) && contained_node->is_editable() && !contained_node->has_children()) {
838+
node = contained_node;
839+
return IterationDecision::Break;
840+
}
841+
return IterationDecision::Continue;
842+
});
843+
if (!node)
844+
return {};
845+
846+
// 4. While node's parent is editable and in the same editing host as node, and node is not an HTML element whose
847+
// local name is a formattable block name, set node to its parent.
848+
while (node->parent() && node->parent()->is_editable() && is_in_same_editing_host(*node, *node->parent())
849+
&& !(is<HTML::HTMLElement>(*node) && is_formattable_block_name(static_cast<DOM::Element&>(*node).local_name())))
850+
node = node->parent();
851+
852+
// 5. If node is an editable HTML element whose local name is a formattable block name, and node is not the ancestor
853+
// of a prohibited paragraph child, return node's local name, converted to ASCII lowercase.
854+
if (node->is_editable() && is<HTML::HTMLElement>(*node)
855+
&& is_formattable_block_name(static_cast<DOM::Element&>(*node).local_name())) {
856+
bool is_ancestor_of_prohibited_paragraph_child = false;
857+
node->for_each_in_subtree([&is_ancestor_of_prohibited_paragraph_child](GC::Ref<DOM::Node> descendant) {
858+
if (is_prohibited_paragraph_child(descendant)) {
859+
is_ancestor_of_prohibited_paragraph_child = true;
860+
return TraversalDecision::Break;
861+
}
862+
return TraversalDecision::Continue;
863+
});
864+
if (!is_ancestor_of_prohibited_paragraph_child)
865+
return static_cast<DOM::Element&>(*node).local_name().to_string().to_ascii_lowercase();
866+
}
867+
868+
// 6. Return the empty string.
869+
return {};
870+
}
871+
618872
// https://w3c.github.io/editing/docs/execCommand/#the-forwarddelete-command
619873
bool command_forward_delete_action(DOM::Document& document, String const&)
620874
{
@@ -1524,6 +1778,14 @@ static Array const commands {
15241778
.action = command_fore_color_action,
15251779
.relevant_css_property = CSS::PropertyID::Color,
15261780
},
1781+
// https://w3c.github.io/editing/docs/execCommand/#the-formatblock-command
1782+
CommandDefinition {
1783+
.command = CommandNames::formatBlock,
1784+
.action = command_format_block_action,
1785+
.indeterminate = command_format_block_indeterminate,
1786+
.value = command_format_block_value,
1787+
.preserves_overrides = true,
1788+
},
15271789
// https://w3c.github.io/editing/docs/execCommand/#the-forwarddelete-command
15281790
CommandDefinition {
15291791
.command = CommandNames::forwardDelete,

Libraries/LibWeb/Editing/Commands.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ bool command_font_name_action(DOM::Document&, String const&);
3939
bool command_font_size_action(DOM::Document&, String const&);
4040
String command_font_size_value(DOM::Document const&);
4141
bool command_fore_color_action(DOM::Document&, String const&);
42+
bool command_format_block_action(DOM::Document&, String const&);
43+
bool command_format_block_indeterminate(DOM::Document const&);
44+
String command_format_block_value(DOM::Document const&);
4245
bool command_forward_delete_action(DOM::Document&, String const&);
4346
bool command_insert_linebreak_action(DOM::Document&, String const&);
4447
bool command_insert_paragraph_action(DOM::Document&, String const&);

Libraries/LibWeb/Editing/Internal/Algorithms.cpp

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2030,6 +2030,15 @@ bool is_extraneous_line_break(GC::Ref<DOM::Node> node)
20302030
return false;
20312031
}
20322032

2033+
// https://w3c.github.io/editing/docs/execCommand/#formattable-block-name
2034+
bool is_formattable_block_name(FlyString const& local_name)
2035+
{
2036+
// A formattable block name is "address", "dd", "div", "dt", "h1", "h2", "h3", "h4", "h5", "h6", "p", or "pre".
2037+
return local_name.is_one_of(HTML::TagNames::address, HTML::TagNames::dd, HTML::TagNames::div, HTML::TagNames::dt,
2038+
HTML::TagNames::h1, HTML::TagNames::h2, HTML::TagNames::h3, HTML::TagNames::h4, HTML::TagNames::h5,
2039+
HTML::TagNames::h6, HTML::TagNames::p, HTML::TagNames::pre);
2040+
}
2041+
20332042
// https://w3c.github.io/editing/docs/execCommand/#formattable-node
20342043
bool is_formattable_node(GC::Ref<DOM::Node> node)
20352044
{
@@ -3197,9 +3206,8 @@ void remove_extraneous_line_breaks_from_a_node(GC::Ref<DOM::Node> node)
31973206
// https://w3c.github.io/editing/docs/execCommand/#preserving-its-descendants
31983207
void remove_node_preserving_its_descendants(GC::Ref<DOM::Node> node)
31993208
{
3200-
// To remove a node node while preserving its descendants, split the parent of node's children
3201-
// if it has any.
3202-
if (node->child_count() > 0) {
3209+
// To remove a node node while preserving its descendants, split the parent of node's children if it has any.
3210+
if (node->has_children()) {
32033211
Vector<GC::Ref<DOM::Node>> children;
32043212
children.ensure_capacity(node->child_count());
32053213
for (auto* child = node->first_child(); child; child = child->next_sibling())

Libraries/LibWeb/Editing/Internal/Algorithms.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ bool is_collapsed_whitespace_node(GC::Ref<DOM::Node>);
5757
bool is_effectively_contained_in_range(GC::Ref<DOM::Node>, GC::Ref<DOM::Range>);
5858
bool is_element_with_inline_contents(GC::Ref<DOM::Node>);
5959
bool is_extraneous_line_break(GC::Ref<DOM::Node>);
60+
bool is_formattable_block_name(FlyString const&);
6061
bool is_formattable_node(GC::Ref<DOM::Node>);
6162
bool is_in_same_editing_host(GC::Ref<DOM::Node>, GC::Ref<DOM::Node>);
6263
bool is_indentation_element(GC::Ref<DOM::Node>);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<p>
2+
foo<br>bar
3+
</p><h1>foo<br>bar<br>
4+
</h1>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script src="../include.js"></script>
2+
<div contenteditable="true">
3+
<div id="d1">foo<br>bar</div>
4+
<div id="d2">foo<br>bar</div>
5+
</div>
6+
<script>
7+
test(() => {
8+
const range = document.createRange();
9+
getSelection().addRange(range);
10+
11+
const div1 = document.querySelector('#d1');
12+
range.setStart(div1.childNodes[0], 0);
13+
range.setEnd(div1, 3);
14+
document.execCommand('formatBlock', false, 'p');
15+
16+
const div2 = document.querySelector('#d2');
17+
range.setStart(div2.childNodes[0], 0);
18+
range.setEnd(div2, 3);
19+
document.execCommand('formatBlock', false, 'h1');
20+
21+
println(document.querySelector('div[contenteditable]').innerHTML);
22+
});
23+
</script>

0 commit comments

Comments
 (0)