From 7bb865052a0365b3f397abc16a5ef021bcbeec5a Mon Sep 17 00:00:00 2001 From: Jelle Raaijmakers Date: Wed, 27 Nov 2024 11:57:12 +0100 Subject: [PATCH] LibWeb: Implement document.execCommand("delete") To facilitate the implementation of "delete" and all associated algorithms, split off this piece of `Document` into a separate directory. This sets up the infrastructure for arbitrary commands to be supported. --- Libraries/LibWeb/Bindings/MainThreadVM.cpp | 2 + Libraries/LibWeb/CMakeLists.txt | 4 + Libraries/LibWeb/DOM/Document.cpp | 42 - Libraries/LibWeb/DOM/Document.h | 12 +- Libraries/LibWeb/DOM/Document.idl | 1 + Libraries/LibWeb/Editing/CommandNames.cpp | 29 + Libraries/LibWeb/Editing/CommandNames.h | 62 + Libraries/LibWeb/Editing/Commands.cpp | 340 ++++ Libraries/LibWeb/Editing/Commands.h | 26 + Libraries/LibWeb/Editing/ExecCommand.cpp | 201 +++ .../LibWeb/Editing/Internal/Algorithms.cpp | 1498 +++++++++++++++++ .../LibWeb/Editing/Internal/Algorithms.h | 58 + 12 files changed, 2227 insertions(+), 48 deletions(-) create mode 100644 Libraries/LibWeb/Editing/CommandNames.cpp create mode 100644 Libraries/LibWeb/Editing/CommandNames.h create mode 100644 Libraries/LibWeb/Editing/Commands.cpp create mode 100644 Libraries/LibWeb/Editing/Commands.h create mode 100644 Libraries/LibWeb/Editing/ExecCommand.cpp create mode 100644 Libraries/LibWeb/Editing/Internal/Algorithms.cpp create mode 100644 Libraries/LibWeb/Editing/Internal/Algorithms.h diff --git a/Libraries/LibWeb/Bindings/MainThreadVM.cpp b/Libraries/LibWeb/Bindings/MainThreadVM.cpp index 252ee996556..feb74cf719b 100644 --- a/Libraries/LibWeb/Bindings/MainThreadVM.cpp +++ b/Libraries/LibWeb/Bindings/MainThreadVM.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +102,7 @@ ErrorOr initialize_main_thread_vm(HTML::EventLoop::Type type) // These strings could potentially live on the VM similar to CommonPropertyNames. DOM::MutationType::initialize_strings(); + Editing::CommandNames::initialize_strings(); HTML::AttributeNames::initialize_strings(); HTML::CustomElementReactionNames::initialize_strings(); HTML::EventNames::initialize_strings(); diff --git a/Libraries/LibWeb/CMakeLists.txt b/Libraries/LibWeb/CMakeLists.txt index 61d9e9acb22..6ff3a5fc6d3 100644 --- a/Libraries/LibWeb/CMakeLists.txt +++ b/Libraries/LibWeb/CMakeLists.txt @@ -222,6 +222,10 @@ set(SOURCES DOMURL/URLSearchParams.cpp DOMURL/URLSearchParamsIterator.cpp Dump.cpp + Editing/CommandNames.cpp + Editing/Commands.cpp + Editing/ExecCommand.cpp + Editing/Internal/Algorithms.cpp Encoding/TextDecoder.cpp Encoding/TextEncoder.cpp EntriesAPI/FileSystemEntry.cpp diff --git a/Libraries/LibWeb/DOM/Document.cpp b/Libraries/LibWeb/DOM/Document.cpp index 969307488f0..f9fcec906f0 100644 --- a/Libraries/LibWeb/DOM/Document.cpp +++ b/Libraries/LibWeb/DOM/Document.cpp @@ -5077,48 +5077,6 @@ JS::Value Document::named_item_value(FlyString const& name) const }); } -// https://w3c.github.io/editing/docs/execCommand/#execcommand() -bool Document::exec_command(String const& command, bool show_ui, String const& value) -{ - dbgln("FIXME: document.execCommand(\"{}\", {}, \"{}\")", command, show_ui, value); - return false; -} - -// https://w3c.github.io/editing/docs/execCommand/#querycommandenabled() -bool Document::query_command_enabled(String const& command) -{ - dbgln("FIXME: document.queryCommandEnabled(\"{}\")", command); - return false; -} - -// https://w3c.github.io/editing/docs/execCommand/#querycommandindeterm() -bool Document::query_command_indeterm(String const& command) -{ - dbgln("FIXME: document.queryCommandIndeterm(\"{}\")", command); - return false; -} - -// https://w3c.github.io/editing/docs/execCommand/#querycommandstate() -bool Document::query_command_state(String const& command) -{ - dbgln("FIXME: document.queryCommandState(\"{}\")", command); - return false; -} - -// https://w3c.github.io/editing/docs/execCommand/#querycommandsupported() -bool Document::query_command_supported(String const& command) -{ - dbgln("FIXME: document.queryCommandSupported(\"{}\")", command); - return false; -} - -// https://w3c.github.io/editing/docs/execCommand/#querycommandvalue() -String Document::query_command_value(String const& command) -{ - dbgln("FIXME: document.queryCommandValue(\"{}\")", command); - return String {}; -} - // https://drafts.csswg.org/resize-observer-1/#calculate-depth-for-node static size_t calculate_depth_for_node(Node const& node) { diff --git a/Libraries/LibWeb/DOM/Document.h b/Libraries/LibWeb/DOM/Document.h index 0f187f1ccef..5c8095c95cc 100644 --- a/Libraries/LibWeb/DOM/Document.h +++ b/Libraries/LibWeb/DOM/Document.h @@ -569,12 +569,12 @@ public: void set_previous_document_unload_timing(DocumentUnloadTimingInfo const& previous_document_unload_timing) { m_previous_document_unload_timing = previous_document_unload_timing; } // https://w3c.github.io/editing/docs/execCommand/ - bool exec_command(String const& command, bool show_ui, String const& value); - bool query_command_enabled(String const& command); - bool query_command_indeterm(String const& command); - bool query_command_state(String const& command); - bool query_command_supported(String const& command); - String query_command_value(String const& command); + bool exec_command(FlyString const& command, bool show_ui, String const& value); + bool query_command_enabled(FlyString const& command); + bool query_command_indeterm(FlyString const& command); + bool query_command_state(FlyString const& command); + bool query_command_supported(FlyString const& command); + String query_command_value(FlyString const& command); // https://w3c.github.io/selection-api/#dfn-has-scheduled-selectionchange-event bool has_scheduled_selectionchange_event() const { return m_has_scheduled_selectionchange_event; } diff --git a/Libraries/LibWeb/DOM/Document.idl b/Libraries/LibWeb/DOM/Document.idl index 10b5b1dc545..ebae6091d04 100644 --- a/Libraries/LibWeb/DOM/Document.idl +++ b/Libraries/LibWeb/DOM/Document.idl @@ -143,6 +143,7 @@ interface Document : Node { readonly attribute Element? scrollingElement; // https://w3c.github.io/editing/docs/execCommand/ + // FIXME: [CEReactions] boolean execCommand(DOMString commandId, optional boolean showUI = false, optional (TrustedHTML or DOMString) value = ""); [CEReactions] boolean execCommand(DOMString commandId, optional boolean showUI = false, optional DOMString value = ""); boolean queryCommandEnabled(DOMString commandId); boolean queryCommandIndeterm(DOMString commandId); diff --git a/Libraries/LibWeb/Editing/CommandNames.cpp b/Libraries/LibWeb/Editing/CommandNames.cpp new file mode 100644 index 00000000000..c3a63cb80f0 --- /dev/null +++ b/Libraries/LibWeb/Editing/CommandNames.cpp @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +namespace Web::Editing::CommandNames { + +#define __ENUMERATE_COMMAND_NAME(name) FlyString name; +ENUMERATE_COMMAND_NAMES +#undef __ENUMERATE_COMMAND_NAME +FlyString delete_; + +void initialize_strings() +{ + static bool s_initialized = false; + VERIFY(!s_initialized); + +#define __ENUMERATE_COMMAND_NAME(name) name = #name##_fly_string; + ENUMERATE_COMMAND_NAMES +#undef __ENUMERATE_MATHML_TAG + delete_ = "delete"_fly_string; + + s_initialized = true; +} + +} diff --git a/Libraries/LibWeb/Editing/CommandNames.h b/Libraries/LibWeb/Editing/CommandNames.h new file mode 100644 index 00000000000..717ccc1866b --- /dev/null +++ b/Libraries/LibWeb/Editing/CommandNames.h @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Web::Editing::CommandNames { + +#define ENUMERATE_COMMAND_NAMES \ + __ENUMERATE_COMMAND_NAME(backColor) \ + __ENUMERATE_COMMAND_NAME(bold) \ + __ENUMERATE_COMMAND_NAME(copy) \ + __ENUMERATE_COMMAND_NAME(createLink) \ + __ENUMERATE_COMMAND_NAME(cut) \ + __ENUMERATE_COMMAND_NAME(defaultParagraphSeparator) \ + __ENUMERATE_COMMAND_NAME(fontName) \ + __ENUMERATE_COMMAND_NAME(fontSize) \ + __ENUMERATE_COMMAND_NAME(foreColor) \ + __ENUMERATE_COMMAND_NAME(formatBlock) \ + __ENUMERATE_COMMAND_NAME(forwardDelete) \ + __ENUMERATE_COMMAND_NAME(hiliteColor) \ + __ENUMERATE_COMMAND_NAME(indent) \ + __ENUMERATE_COMMAND_NAME(insertHTML) \ + __ENUMERATE_COMMAND_NAME(insertHorizontalRule) \ + __ENUMERATE_COMMAND_NAME(insertImage) \ + __ENUMERATE_COMMAND_NAME(insertLineBreak) \ + __ENUMERATE_COMMAND_NAME(insertOrderedList) \ + __ENUMERATE_COMMAND_NAME(insertParagraph) \ + __ENUMERATE_COMMAND_NAME(insertText) \ + __ENUMERATE_COMMAND_NAME(insertUnorderedList) \ + __ENUMERATE_COMMAND_NAME(italic) \ + __ENUMERATE_COMMAND_NAME(justifyCenter) \ + __ENUMERATE_COMMAND_NAME(justifyFull) \ + __ENUMERATE_COMMAND_NAME(justifyLeft) \ + __ENUMERATE_COMMAND_NAME(justifyRight) \ + __ENUMERATE_COMMAND_NAME(outdent) \ + __ENUMERATE_COMMAND_NAME(paste) \ + __ENUMERATE_COMMAND_NAME(redo) \ + __ENUMERATE_COMMAND_NAME(removeFormat) \ + __ENUMERATE_COMMAND_NAME(selectAll) \ + __ENUMERATE_COMMAND_NAME(strikethrough) \ + __ENUMERATE_COMMAND_NAME(styleWithCSS) \ + __ENUMERATE_COMMAND_NAME(subscript) \ + __ENUMERATE_COMMAND_NAME(superscript) \ + __ENUMERATE_COMMAND_NAME(underline) \ + __ENUMERATE_COMMAND_NAME(undo) \ + __ENUMERATE_COMMAND_NAME(unlink) \ + __ENUMERATE_COMMAND_NAME(useCSS) + +#define __ENUMERATE_COMMAND_NAME(name) extern FlyString name; +ENUMERATE_COMMAND_NAMES +#undef __ENUMERATE_COMMAND_NAME + +extern FlyString delete_; + +void initialize_strings(); + +} diff --git a/Libraries/LibWeb/Editing/Commands.cpp b/Libraries/LibWeb/Editing/Commands.cpp new file mode 100644 index 00000000000..f1a711d5afb --- /dev/null +++ b/Libraries/LibWeb/Editing/Commands.cpp @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Web::Editing { + +// https://w3c.github.io/editing/docs/execCommand/#the-delete-command +bool command_delete_action(DOM::Document& document, String const&) +{ + // 1. If the active range is not collapsed, delete the selection and return true. + auto& selection = *document.get_selection(); + auto& active_range = *selection.range(); + if (!active_range.collapsed()) { + delete_the_selection(selection); + return true; + } + + // 2. Canonicalize whitespace at the active range's start. + canonicalize_whitespace(active_range.start_container(), active_range.start_offset()); + + // 3. Let node and offset be the active range's start node and offset. + auto node = active_range.start_container(); + int offset = active_range.start_offset(); + + // 4. Repeat the following steps: + GC::Ptr offset_minus_one_child; + while (true) { + offset_minus_one_child = node->child_at_index(offset - 1); + + // 1. If offset is zero and node's previousSibling is an editable invisible node, remove + // node's previousSibling from its parent. + if (auto* previous_sibling = node->previous_sibling()) { + if (offset == 0 && previous_sibling->is_editable() && is_invisible_node(*previous_sibling)) { + previous_sibling->remove(); + continue; + } + } + + // 2. Otherwise, if node has a child with index offset − 1 and that child is an editable + // invisible node, remove that child from node, then subtract one from offset. + if (offset_minus_one_child && offset_minus_one_child->is_editable() && is_invisible_node(*offset_minus_one_child)) { + offset_minus_one_child->remove(); + --offset; + continue; + } + + // 3. Otherwise, if offset is zero and node is an inline node, or if node is an invisible + // node, set offset to the index of node, then set node to its parent. + if ((offset == 0 && is_inline_node(node)) || is_invisible_node(node)) { + offset = node->index(); + node = *node->parent(); + continue; + } + + // 4. Otherwise, if node has a child with index offset − 1 and that child is an editable a, + // remove that child from node, preserving its descendants. Then return true. + if (is(offset_minus_one_child.ptr()) && offset_minus_one_child->is_editable()) { + remove_node_preserving_its_descendants(*offset_minus_one_child); + return true; + } + + // 5. Otherwise, if node has a child with index offset − 1 and that child is not a block + // node or a br or an img, set node to that child, then set offset to the length of node. + if (offset_minus_one_child && !is_block_node(*offset_minus_one_child) + && !is(*offset_minus_one_child) && !is(*offset_minus_one_child)) { + node = *offset_minus_one_child; + offset = node->length(); + continue; + } + + // 6. Otherwise, break from this loop. + break; + } + + // 5. If node is a Text node and offset is not zero, or if node is a block node that has a child + // with index offset − 1 and that child is a br or hr or img: + bool block_node_child_is_relevant_type = false; + if (is_block_node(node)) { + if (auto* child_node = node->child_at_index(offset - 1)) { + auto& child_element = static_cast(*child_node); + block_node_child_is_relevant_type = child_element.local_name().is_one_of(HTML::TagNames::br, HTML::TagNames::hr, HTML::TagNames::img); + } + } + if ((is(*node) && offset != 0) || block_node_child_is_relevant_type) { + // 1. Call collapse(node, offset) on the context object's selection. + MUST(selection.collapse(node, offset)); + + // 2. Call extend(node, offset − 1) on the context object's selection. + MUST(selection.extend(*node, offset - 1)); + + // 3. Delete the selection. + delete_the_selection(selection); + + // 4. Return true. + return true; + } + + // 6. If node is an inline node, return true. + if (is_inline_node(node)) + return true; + + // 7. If node is an li or dt or dd and is the first child of its parent, and offset is zero: + auto& node_element = static_cast(*node); + if (offset == 0 && node->index() == 0 + && node_element.local_name().is_one_of(HTML::TagNames::li, HTML::TagNames::dt, HTML::TagNames::dd)) { + // 1. Let items be a list of all lis that are ancestors of node. + auto items = Vector>(); + GC::Ptr ancestor = node->parent(); + do { + auto& ancestor_element = static_cast(*ancestor); + if (ancestor_element.local_name() == HTML::TagNames::li) + items.append(ancestor_element); + ancestor = ancestor->parent(); + } while (ancestor); + + // 2. Normalize sublists of each item in items. + for (auto item : items) + normalize_sublists_in_node(*item); + + // 3. Record the values of the one-node list consisting of node, and let values be the + // result. + auto values = record_the_values_of_nodes({ node }); + + // 4. Split the parent of the one-node list consisting of node. + split_the_parent_of_nodes({ node }); + + // 5. Restore the values from values. + restore_the_values_of_nodes(values); + + // FIXME: 6. If node 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 node to the default single-line container name + // and let node be the result. + + // FIXME: 7. Fix disallowed ancestors of node. + + // 8. Return true. + return true; + } + + // 8. Let start node equal node and let start offset equal offset. + auto start_node = node; + auto start_offset = offset; + + // 9. Repeat the following steps: + while (true) { + // 1. If start offset is zero, set start offset to the index of start node and then set + // start node to its parent. + if (start_offset == 0) { + start_offset = start_node->index(); + start_node = *start_node->parent(); + continue; + } + + // 2. Otherwise, if start node has an editable invisible child with index start offset minus + // one, remove it from start node and subtract one from start offset. + offset_minus_one_child = start_node->child_at_index(start_offset - 1); + if (offset_minus_one_child && offset_minus_one_child->is_editable() && is_invisible_node(*offset_minus_one_child)) { + offset_minus_one_child->remove(); + --start_offset; + continue; + } + + // 3. Otherwise, break from this loop. + break; + } + + // FIXME: 10. If offset is zero, and node has an editable inclusive ancestor in the same editing host + // that's an indentation element: + if (false) { + // FIXME: 1. Block-extend the range whose start and end are both (node, 0), and let new range be + // the result. + + // FIXME: 2. Let node list be a list of nodes, initially empty. + + // FIXME: 3. For each node current node contained in new range, append current node to node list if + // the last member of node list (if any) is not an ancestor of current node, and current + // node is editable but has no editable descendants. + + // FIXME: 4. Outdent each node in node list. + + // 5. Return true. + return true; + } + + // 11. If the child of start node with index start offset is a table, return true. + if (is(start_node->child_at_index(start_offset))) + return true; + + // 12. If start node has a child with index start offset − 1, and that child is a table: + offset_minus_one_child = start_node->child_at_index(start_offset - 1); + if (is(offset_minus_one_child.ptr())) { + // 1. Call collapse(start node, start offset − 1) on the context object's selection. + MUST(selection.collapse(start_node, start_offset - 1)); + + // 2. Call extend(start node, start offset) on the context object's selection. + MUST(selection.extend(*start_node, start_offset)); + + // 3. Return true. + return true; + } + + // 13. If offset is zero; and either the child of start node with index start offset minus one + // is an hr, or the child is a br whose previousSibling is either a br or not an inline + // node: + if (offset == 0 && is(offset_minus_one_child.ptr())) { + auto& child_element = static_cast(*offset_minus_one_child); + auto* previous_sibling = child_element.previous_sibling(); + if (child_element.local_name() == HTML::TagNames::hr + || (is(child_element) && previous_sibling && (is(*previous_sibling) || !is_inline_node(*previous_sibling)))) { + // 1. Call collapse(start node, start offset − 1) on the context object's selection. + MUST(selection.collapse(start_node, start_offset - 1)); + + // 2. Call extend(start node, start offset) on the context object's selection. + MUST(selection.extend(*start_node, start_offset)); + + // 3. Delete the selection. + delete_the_selection(selection); + + // 4. Call collapse(node, offset) on the selection. + MUST(selection.collapse(node, offset)); + + // 5. Return true. + return true; + } + } + + // 14. If the child of start node with index start offset is an li or dt or dd, and that child's + // firstChild is an inline node, and start offset is not zero: + 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); + }; + auto* start_offset_child = start_node->child_at_index(start_offset); + if (start_offset != 0 && is(start_offset_child) + && is_li_dt_or_dd(static_cast(*start_offset_child)) + && start_offset_child->has_children() && is_inline_node(*start_offset_child->first_child())) { + // 1. Let previous item be the child of start node with index start offset minus one. + GC::Ref previous_item = *start_node->child_at_index(start_offset - 1); + + // 2. If previous item's lastChild is an inline node other than a br, call + // createElement("br") on the context object and append the result as the last child of + // previous item. + GC::Ptr previous_item_last_child = previous_item->last_child(); + if (previous_item_last_child && is_inline_node(*previous_item_last_child) && !is(*previous_item_last_child)) { + auto br_element = MUST(DOM::create_element(previous_item->document(), HTML::TagNames::br, Namespace::HTML)); + MUST(previous_item->append_child(br_element)); + } + + // 3. If previous item's lastChild is an inline node, call createElement("br") on the + // context object and append the result as the last child of previous item. + if (previous_item_last_child && is_inline_node(*previous_item_last_child)) { + auto br_element = MUST(DOM::create_element(previous_item->document(), HTML::TagNames::br, Namespace::HTML)); + MUST(previous_item->append_child(br_element)); + } + } + + // FIXME: 15. If start node's child with index start offset is an li or dt or dd, and that child's + // previousSibling is also an li or dt or dd: + if (false) { + // FIXME: 1. Call cloneRange() on the active range, and let original range be the result. + + // FIXME: 2. Set start node to its child with index start offset − 1. + + // FIXME: 3. Set start offset to start node's length. + + // FIXME: 4. Set node to start node's nextSibling. + + // FIXME: 5. Call collapse(start node, start offset) on the context object's selection. + + // FIXME: 6. Call extend(node, 0) on the context object's selection. + + // FIXME: 7. Delete the selection. + + // FIXME: 8. Call removeAllRanges() on the context object's selection. + + // FIXME: 9. Call addRange(original range) on the context object's selection. + + // 10. Return true. + return true; + } + + // 16. While start node has a child with index start offset minus one: + while (start_node->child_at_index(start_offset - 1) != nullptr) { + // 1. If start node's child with index start offset minus one is editable and invisible, + // remove it from start node, then subtract one from start offset. + offset_minus_one_child = start_node->child_at_index(start_offset - 1); + if (offset_minus_one_child->is_editable() && is_invisible_node(*offset_minus_one_child)) { + offset_minus_one_child->remove(); + --start_offset; + } + + // 2. Otherwise, set start node to its child with index start offset minus one, then set + // start offset to the length of start node. + else { + start_node = *offset_minus_one_child; + start_offset = start_node->length(); + } + } + + // 17. Call collapse(start node, start offset) on the context object's selection. + MUST(selection.collapse(start_node, start_offset)); + + // 18. Call extend(node, offset) on the context object's selection. + MUST(selection.extend(*node, offset)); + + // FIXME: 19. Delete the selection, with direction "backward". + delete_the_selection(selection); + + // 20. Return true. + return true; +} + +static Array const commands { + CommandDefinition { CommandNames::delete_, command_delete_action, {}, {}, {} }, +}; + +Optional find_command_definition(FlyString const& command) +{ + for (auto& definition : commands) { + if (command.equals_ignoring_ascii_case(definition.command)) + return definition; + } + return {}; +} + +} diff --git a/Libraries/LibWeb/Editing/Commands.h b/Libraries/LibWeb/Editing/Commands.h new file mode 100644 index 00000000000..a8b3fe8ccd8 --- /dev/null +++ b/Libraries/LibWeb/Editing/Commands.h @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Web::Editing { + +struct CommandDefinition { + FlyString const& command; + Function action; + Function indeterminate; + Function state; + Function value; +}; + +Optional find_command_definition(FlyString const&); + +// Command implementations +bool command_delete_action(DOM::Document&, String const&); + +} diff --git a/Libraries/LibWeb/Editing/ExecCommand.cpp b/Libraries/LibWeb/Editing/ExecCommand.cpp new file mode 100644 index 00000000000..3962723a6bc --- /dev/null +++ b/Libraries/LibWeb/Editing/ExecCommand.cpp @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include + +namespace Web::DOM { + +// https://w3c.github.io/editing/docs/execCommand/#execcommand() +bool Document::exec_command(FlyString const& command, [[maybe_unused]] bool show_ui, String const& value) +{ + // 1. If only one argument was provided, let show UI be false. + // 2. If only one or two arguments were provided, let value be the empty string. + // NOTE: these steps are dealt by the default values for both show_ui and value + + // 3. If command is not supported or not enabled, return false. + // NOTE: query_command_enabled() also checks if command is supported + if (!query_command_enabled(command)) + return false; + + // 4. If command is not in the Miscellaneous commands section: + // + // We don't fire events for copy/cut/paste/undo/redo/selectAll because they should all have + // their own events. We don't fire events for styleWithCSS/useCSS because it's not obvious + // where to fire them, or why anyone would want them. We don't fire events for unsupported + // commands, because then if they became supported and were classified with the miscellaneous + // events, we'd have to stop firing events for consistency's sake. + // + // AD-HOC: The defaultParagraphSeparator command is also in the Miscellaneous commands section + if (command != Editing::CommandNames::defaultParagraphSeparator + && command != Editing::CommandNames::redo + && command != Editing::CommandNames::selectAll + && command != Editing::CommandNames::styleWithCSS + && command != Editing::CommandNames::undo + && command != Editing::CommandNames::useCSS) { + // FIXME: 1. Let affected editing host be the editing host that is an inclusive ancestor of the + // active range's start node and end node, and is not the ancestor of any editing host + // that is an inclusive ancestor of the active range's start node and end node. + + // FIXME: 2. Fire an event named "beforeinput" at affected editing host using InputEvent, with its + // bubbles and cancelable attributes initialized to true, and its data attribute + // initialized to null. + + // FIXME: 3. If the value returned by the previous step is false, return false. + + // 4. If command is not enabled, return false. + // + // We have to check again whether the command is enabled, because the beforeinput handler + // might have done something annoying like getSelection().removeAllRanges(). + if (!query_command_enabled(command)) + return false; + + // FIXME: 5. Let affected editing host be the editing host that is an inclusive ancestor of the + // active range's start node and end node, and is not the ancestor of any editing host + // that is an inclusive ancestor of the active range's start node and end node. + // + // This new affected editing host is what we'll fire the input event at in a couple of + // lines. We want to compute it beforehand just to be safe: bugs in the command action + // might remove the selection or something bad like that, and we don't want to have to + // handle it later. We recompute it after the beforeinput event is handled so that if the + // handler moves the selection to some other editing host, the input event will be fired + // at the editing host that was actually affected. + } + + // 5. Take the action for command, passing value to the instructions as an argument. + auto optional_command = Editing::find_command_definition(command); + VERIFY(optional_command.has_value()); + auto const& command_definition = optional_command.release_value(); + auto command_result = command_definition.action(*this, value); + + // 6. If the previous step returned false, return false. + if (!command_result) + return false; + + // FIXME: 7. If the action modified DOM tree, then fire an event named "input" at affected editing host + // using InputEvent, with its isTrusted and bubbles attributes initialized to true, inputType + // attribute initialized to the mapped value of command, and its data attribute initialized + // to null. + + // 8. Return true. + return true; +} + +// https://w3c.github.io/editing/docs/execCommand/#querycommandenabled() +bool Document::query_command_enabled(FlyString const& command) +{ + // 2. Return true if command is both supported and enabled, false otherwise. + if (!query_command_supported(command)) + return false; + + // https://w3c.github.io/editing/docs/execCommand/#enabled + // Among commands defined in this specification, those listed in Miscellaneous commands are + // always enabled, except for the cut command and the paste command. + // AD-HOC: Cut and Paste are not in the Miscellaneous commands section; so Copy is assumed + // AD-HOC: DefaultParagraphSeparator is also in the Miscellaneous commands section + if (command == Editing::CommandNames::copy + || command == Editing::CommandNames::defaultParagraphSeparator + || command == Editing::CommandNames::redo + || command == Editing::CommandNames::selectAll + || command == Editing::CommandNames::styleWithCSS + || command == Editing::CommandNames::undo + || command == Editing::CommandNames::useCSS) + return true; + + // The other commands defined here are enabled if the active range is not null, + auto selection = get_selection(); + if (!selection) + return false; + auto active_range = selection->range(); + if (!active_range) + return false; + + // its start node is either editable or an editing host, + auto& start_node = *active_range->start_container(); + if (!start_node.is_editable() && !Editing::is_editing_host(start_node)) + return false; + + // FIXME: the editing host of its start node is not an EditContext editing host, + + // its end node is either editable or an editing host, + auto& end_node = *active_range->end_container(); + if (!end_node.is_editable() && !Editing::is_editing_host(end_node)) + return false; + + // FIXME: the editing host of its end node is not an EditContext editing host, + + // FIXME: and there is some editing host that is an inclusive ancestor of both its start node and its + // end node. + + return true; +} + +// https://w3c.github.io/editing/docs/execCommand/#querycommandindeterm() +bool Document::query_command_indeterm(FlyString const& command) +{ + // 1. If command is not supported or has no indeterminacy, return false. + auto optional_command = Editing::find_command_definition(command); + if (!optional_command.has_value()) + return false; + auto const& command_definition = optional_command.value(); + if (!command_definition.indeterminate) + return false; + + // 2. Return true if command is indeterminate, otherwise false. + return command_definition.indeterminate(*this); +} + +// https://w3c.github.io/editing/docs/execCommand/#querycommandstate() +bool Document::query_command_state(FlyString const& command) +{ + // 1. If command is not supported or has no state, return false. + auto optional_command = Editing::find_command_definition(command); + if (!optional_command.has_value()) + return false; + auto const& command_definition = optional_command.release_value(); + if (!command_definition.state) + return false; + + // FIXME: 2. If the state override for command is set, return it. + + // 3. Return true if command's state is true, otherwise false. + return command_definition.state(*this); +} + +// https://w3c.github.io/editing/docs/execCommand/#querycommandsupported() +bool Document::query_command_supported(FlyString const& command) +{ + // When the queryCommandSupported(command) method on the Document interface is invoked, the + // user agent must return true if command is supported and available within the current script + // on the current site, and false otherwise. + return Editing::find_command_definition(command).has_value(); +} + +// https://w3c.github.io/editing/docs/execCommand/#querycommandvalue() +String Document::query_command_value(FlyString const& command) +{ + // 1. If command is not supported or has no value, return the empty string. + auto optional_command = Editing::find_command_definition(command); + if (!optional_command.has_value()) + return {}; + auto const& command_definition = optional_command.release_value(); + if (!command_definition.value) + return {}; + + // FIXME: 2. If command is "fontSize" and its value override is set, convert the value override to an + // integer number of pixels and return the legacy font size for the result. + + // FIXME: 3. If the value override for command is set, return it. + + // 4. Return command's value. + return command_definition.value(*this); +} + +} diff --git a/Libraries/LibWeb/Editing/Internal/Algorithms.cpp b/Libraries/LibWeb/Editing/Internal/Algorithms.cpp new file mode 100644 index 00000000000..7905d0bb620 --- /dev/null +++ b/Libraries/LibWeb/Editing/Internal/Algorithms.cpp @@ -0,0 +1,1498 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Web::Editing { + +// https://w3c.github.io/editing/docs/execCommand/#canonical-space-sequence +String canonical_space_sequence(u32 length, bool non_breaking_start, bool non_breaking_end) +{ + auto n = length; + + // 1. If n is zero, return the empty string. + if (n == 0) + return {}; + + // 2. If n is one and both non-breaking start and non-breaking end are false, return a single + // space (U+0020). + if (n == 1 && !non_breaking_start && !non_breaking_end) + return " "_string; + + // 3. If n is one, return a single non-breaking space (U+00A0). + if (n == 1) + return "\u00A0"_string; + + // 4. Let buffer be the empty string. + StringBuilder buffer; + + // 5. If non-breaking start is true, let repeated pair be U+00A0 U+0020. Otherwise, let it be + // U+0020 U+00A0. + auto repeated_pair = non_breaking_start ? "\u00A0 "sv : " \u00A0"sv; + + // 6. While n is greater than three, append repeated pair to buffer and subtract two from n. + while (n > 3) { + buffer.append(repeated_pair); + n -= 2; + } + + // 7. If n is three, append a three-code unit string to buffer depending on non-breaking start + // and non-breaking end: + if (n == 3) { + // non-breaking start and non-breaking end false + // U+0020 U+00A0 U+0020 + if (!non_breaking_start && !non_breaking_end) + buffer.append(" \u00A0 "sv); + + // non-breaking start true, non-breaking end false + // U+00A0 U+00A0 U+0020 + else if (non_breaking_start && !non_breaking_end) + buffer.append("\u00A0\u00A0 "sv); + + // non-breaking start false, non-breaking end true + // U+0020 U+00A0 U+00A0 + else if (!non_breaking_start) + buffer.append(" \u00A0\u00A0"sv); + + // non-breaking start and non-breaking end both true + // U+00A0 U+0020 U+00A0 + else + buffer.append("\u00A0 \u00A0"sv); + } + + // 8. Otherwise, append a two-code unit string to buffer depending on non-breaking start and + // non-breaking end: + else { + // non-breaking start and non-breaking end false + // non-breaking start true, non-breaking end false + // U+00A0 U+0020 + if (!non_breaking_start && !non_breaking_end) + buffer.append("\u00A0 "sv); + + // non-breaking start false, non-breaking end true + // U+0020 U+00A0 + else if (!non_breaking_start) + buffer.append(" \u00A0"sv); + + // non-breaking start and non-breaking end both true + // U+00A0 U+00A0 + else + buffer.append("\u00A0\u00A0"sv); + } + + // 9. Return buffer. + return MUST(buffer.to_string()); +} + +// https://w3c.github.io/editing/docs/execCommand/#canonicalize-whitespace +void canonicalize_whitespace(GC::Ref node, u32 offset, bool fix_collapsed_space) +{ + // 1. If node is neither editable nor an editing host, abort these steps. + if (!node->is_editable() || !is_editing_host(node)) + return; + + // 2. Let start node equal node and let start offset equal offset. + auto start_node = node; + auto start_offset = offset; + + // 3. Repeat the following steps: + while (true) { + // 1. If start node has a child in the same editing host with index start offset minus one, + // set start node to that child, then set start offset to start node's length. + auto* offset_minus_one_child = start_node->child_at_index(start_offset - 1); + if (offset_minus_one_child && is_in_same_editing_host(*start_node, *offset_minus_one_child)) { + start_node = *offset_minus_one_child; + start_offset = start_node->length(); + continue; + } + + // 2. Otherwise, if start offset is zero and start node does not follow a line break and + // start node's parent is in the same editing host, set start offset to start node's + // index, then set start node to its parent. + if (start_offset == 0 && !follows_a_line_break(start_node) && is_in_same_editing_host(*start_node, *start_node->parent())) { + start_offset = start_node->index(); + start_node = *start_node->parent(); + continue; + } + + // 3. Otherwise, if start node is a Text node and its parent's resolved value for + // "white-space" is neither "pre" nor "pre-wrap" and start offset is not zero and the + // (start offset − 1)st code unit of start node's data is a space (0x0020) or + // non-breaking space (0x00A0), subtract one from start offset. + auto* layout_node = start_node->parent()->layout_node(); + if (layout_node && is(*start_node) && start_offset != 0) { + auto parent_white_space = layout_node->computed_values().white_space(); + + // FIXME: Find a way to get code points directly from the UTF-8 string + auto start_node_data = *start_node->text_content(); + auto utf16_code_units = MUST(AK::utf8_to_utf16(start_node_data)); + auto offset_minus_one_code_point = Utf16View { utf16_code_units }.code_point_at(start_offset - 1); + if (parent_white_space != CSS::WhiteSpace::Pre && parent_white_space != CSS::WhiteSpace::PreWrap + && (offset_minus_one_code_point == 0x20 || offset_minus_one_code_point == 0xA0)) { + --start_offset; + continue; + } + } + + // 4. Otherwise, break from this loop. + break; + } + + // 4. Let end node equal start node and end offset equal start offset. + auto end_node = start_node; + auto end_offset = start_offset; + + // 5. Let length equal zero. + auto length = 0; + + // 6. Let collapse spaces be true if start offset is zero and start node follows a line break, + // otherwise false. + auto collapse_spaces = start_offset == 0 && follows_a_line_break(start_node); + + // 7. Repeat the following steps: + while (true) { + // 1. If end node has a child in the same editing host with index end offset, set end node + // to that child, then set end offset to zero. + auto* offset_child = end_node->child_at_index(end_offset); + if (offset_child && is_in_same_editing_host(*end_node, *offset_child)) { + end_node = *offset_child; + end_offset = 0; + continue; + } + + // 2. Otherwise, if end offset is end node's length and end node does not precede a line + // break and end node's parent is in the same editing host, set end offset to one plus + // end node's index, then set end node to its parent. + if (end_offset == end_node->length() && !precedes_a_line_break(end_node) && is_in_same_editing_host(*end_node, *end_node->parent())) { + end_offset = end_node->index() + 1; + end_node = *end_node->parent(); + continue; + } + + // 3. Otherwise, if end node is a Text node and its parent's resolved value for + // "white-space" is neither "pre" nor "pre-wrap" and end offset is not end node's length + // and the end offsetth code unit of end node's data is a space (0x0020) or non-breaking + // space (0x00A0): + auto* layout_node = end_node->parent()->layout_node(); + if (layout_node && is(*end_node) && end_offset != end_node->length()) { + auto parent_white_space = layout_node->computed_values().white_space(); + + // FIXME: Find a way to get code points directly from the UTF-8 string + auto end_node_data = *end_node->text_content(); + auto utf16_code_units = MUST(AK::utf8_to_utf16(end_node_data)); + auto offset_code_point = Utf16View { utf16_code_units }.code_point_at(end_offset); + if (parent_white_space != CSS::WhiteSpace::Pre && parent_white_space != CSS::WhiteSpace::PreWrap + && (offset_code_point == 0x20 || offset_code_point == 0xA0)) { + // 1. If fix collapsed space is true, and collapse spaces is true, and the end offsetth + // code unit of end node's data is a space (0x0020): call deleteData(end offset, 1) + // on end node, then continue this loop from the beginning. + if (fix_collapsed_space && collapse_spaces && offset_code_point == 0x20) { + MUST(static_cast(*end_node).delete_data(end_offset, 1)); + continue; + } + + // 2. Set collapse spaces to true if the end offsetth code unit of end node's data is a + // space (0x0020), false otherwise. + collapse_spaces = offset_code_point == 0x20; + + // 3. Add one to end offset. + ++end_offset; + + // 4. Add one to length. + ++length; + + // NOTE: We continue the loop here since we matched every condition from step 7.3 + continue; + } + } + + // 4. Otherwise, break from this loop. + break; + } + + // 8. If fix collapsed space is true, then while (start node, start offset) is before (end node, + // end offset): + if (fix_collapsed_space) { + while (true) { + auto relative_position = position_of_boundary_point_relative_to_other_boundary_point(*start_node, start_offset, *end_node, end_offset); + if (relative_position != DOM::RelativeBoundaryPointPosition::Before) + break; + + // 1. If end node has a child in the same editing host with index end offset − 1, set end + // node to that child, then set end offset to end node's length. + auto offset_minus_one_child = end_node->child_at_index(end_offset - 1); + if (offset_minus_one_child && is_in_same_editing_host(end_node, *offset_minus_one_child)) { + end_node = *offset_minus_one_child; + end_offset = end_node->length(); + continue; + } + + // 2. Otherwise, if end offset is zero and end node's parent is in the same editing host, + // set end offset to end node's index, then set end node to its parent. + if (end_offset == 0 && is_in_same_editing_host(end_node, *end_node->parent())) { + end_offset = end_node->index(); + end_node = *end_node->parent(); + continue; + } + + // 3. Otherwise, if end node is a Text node and its parent's resolved value for + // "white-space" is neither "pre" nor "pre-wrap" and end offset is end node's length and + // the last code unit of end node's data is a space (0x0020) and end node precedes a line + // break: + auto* layout_node = end_node->parent()->layout_node(); + if (layout_node && is(*end_node) && end_offset == end_node->length() && precedes_a_line_break(end_node)) { + auto parent_white_space = layout_node->computed_values().white_space(); + + // FIXME: Find a way to get code points directly from the UTF-8 string + auto end_node_data = *end_node->text_content(); + auto utf16_code_units = MUST(AK::utf8_to_utf16(end_node_data)); + auto utf16_view = Utf16View { utf16_code_units }; + auto last_code_point = utf16_view.code_point_at(utf16_view.length_in_code_points() - 1); + if (parent_white_space != CSS::WhiteSpace::Pre && parent_white_space != CSS::WhiteSpace::PreWrap + && last_code_point == 0x20) { + // 1. Subtract one from end offset. + --end_offset; + + // 2. Subtract one from length. + --length; + + // 3. Call deleteData(end offset, 1) on end node. + MUST(static_cast(*end_node).delete_data(end_offset, 1)); + + // NOTE: We continue the loop here since we matched every condition from step 8.3 + continue; + } + } + + // 4. Otherwise, break from this loop. + break; + } + } + + // 9. Let replacement whitespace be the canonical space sequence of length length. non-breaking + // start is true if start offset is zero and start node follows a line break, and false + // otherwise. non-breaking end is true if end offset is end node's length and end node + // precedes a line break, and false otherwise. + auto replacement_whitespace = canonical_space_sequence( + length, + start_offset == 0 && follows_a_line_break(start_node), + end_offset == end_node->length() && precedes_a_line_break(end_node)); + + // 10. While (start node, start offset) is before (end node, end offset): + while (true) { + auto relative_position = position_of_boundary_point_relative_to_other_boundary_point(start_node, start_offset, end_node, end_offset); + if (relative_position != DOM::RelativeBoundaryPointPosition::Before) + break; + + // 1. If start node has a child with index start offset, set start node to that child, then + // set start offset to zero. + if (start_node->child_at_index(start_offset)) { + start_node = *start_node->child_at_index(start_offset); + start_offset = 0; + } + + // 2. Otherwise, if start node is not a Text node or if start offset is start node's length, + // set start offset to one plus start node's index, then set start node to its parent. + else if (!is(*start_node) || start_offset == start_node->length()) { + start_offset = start_node->index() + 1; + start_node = *start_node->parent(); + } + + // 3. Otherwise: + else { + // 1. Remove the first code unit from replacement whitespace, and let element be that + // code unit. + // FIXME: Find a way to get code points directly from the UTF-8 string + auto replacement_whitespace_utf16 = MUST(AK::utf8_to_utf16(replacement_whitespace)); + auto replacement_whitespace_utf16_view = Utf16View { replacement_whitespace_utf16 }; + replacement_whitespace = MUST(String::from_utf16({ replacement_whitespace_utf16_view.substring_view(1) })); + auto element = replacement_whitespace_utf16_view.code_point_at(0); + + // 2. If element is not the same as the start offsetth code unit of start node's data: + auto start_node_data = *start_node->text_content(); + auto start_node_utf16 = MUST(AK::utf8_to_utf16(start_node_data)); + auto start_node_utf16_view = Utf16View { start_node_utf16 }; + auto start_node_code_point = start_node_utf16_view.code_point_at(start_offset); + if (element != start_node_code_point) { + // 1. Call insertData(start offset, element) on start node. + auto& start_node_character_data = static_cast(*start_node); + MUST(start_node_character_data.insert_data(start_offset, String::from_code_point(element))); + + // 2. Call deleteData(start offset + 1, 1) on start node. + MUST(start_node_character_data.delete_data(start_offset + 1, 1)); + } + + // 3. Add one to start offset. + ++start_offset; + } + } +} + +// https://w3c.github.io/editing/docs/execCommand/#delete-the-selection +void delete_the_selection(Selection::Selection const& selection) +{ + // FIXME: implement the spec + auto active_range = selection.range(); + if (!active_range) + return; + MUST(active_range->delete_contents()); +} + +// https://w3c.github.io/editing/docs/execCommand/#editing-host-of +GC::Ptr editing_host_of_node(GC::Ref node) +{ + // node itself, if node is an editing host; + if (is_editing_host(node)) + return node; + + // or the nearest ancestor of node that is an editing host, if node is editable. + if (node->is_editable()) { + auto* ancestor = node->parent(); + do { + if (is_editing_host(*ancestor)) + return ancestor; + ancestor = ancestor->parent(); + } while (ancestor); + VERIFY_NOT_REACHED(); + } + + // The editing host of node is null if node is neither editable nor an editing host; + return {}; +} + +// https://w3c.github.io/editing/docs/execCommand/#follows-a-line-break +bool follows_a_line_break(GC::Ref node) +{ + // 1. Let offset be zero. + auto offset = 0; + + // 2. While (node, offset) is not a block boundary point: + while (!is_block_boundary_point(node, offset)) { + // 1. If node has a visible child with index offset minus one, return false. + auto* offset_minus_one_child = node->child_at_index(offset - 1); + if (offset_minus_one_child && is_visible_node(*offset_minus_one_child)) + return false; + + // 2. If offset is zero or node has no children, set offset to node's index, then set node + // to its parent. + if (offset == 0 || node->child_count() == 0) { + offset = node->index(); + node = *node->parent(); + } + + // 3. Otherwise, set node to its child with index offset minus one, then set offset to + // node's length. + else { + node = *node->child_at_index(offset - 1); + offset = node->length(); + } + } + + // 3. Return true. + return true; +} + +// https://w3c.github.io/editing/docs/execCommand/#allowed-child +bool is_allowed_child_of_node(Variant, FlyString> child, Variant, FlyString> parent) +{ + GC::Ptr child_node; + if (child.has>()) + child_node = child.get>(); + + // 1. If parent is "colgroup", "table", "tbody", "tfoot", "thead", "tr", or an HTML element with local name equal to + // one of those, and child is a Text node whose data does not consist solely of space characters, return false. + auto parent_local_name = parent.visit( + [](FlyString local_name) { return local_name; }, + [](DOM::Node const* node) { return static_cast(node)->local_name(); }); + auto parent_is_table_like = parent_local_name.is_one_of(HTML::TagNames::colgroup, HTML::TagNames::table, + HTML::TagNames::tbody, HTML::TagNames::tfoot, HTML::TagNames::thead, HTML::TagNames::tr); + if (parent_is_table_like && is(child_node.ptr())) { + auto child_text_content = child_node->text_content().release_value(); + if (!all_of(child_text_content.bytes_as_string_view(), Infra::is_ascii_whitespace)) + return false; + } + + // 2. If parent is "script", "style", "plaintext", or "xmp", or an HTML element with local name equal to one of + // those, and child is not a Text node, return false. + if ((child.has() || !is(*child_node)) + && parent_local_name.is_one_of(HTML::TagNames::script, HTML::TagNames::style, HTML::TagNames::plaintext, HTML::TagNames::xmp)) + return false; + + // 3. If child is a document, DocumentFragment, or DocumentType, return false. + if (child_node && (is(*child_node) || is(*child_node) || is(*child_node))) + return false; + + // 4. If child is an HTML element, set child to the local name of child. + if (is(child_node.ptr())) + child = static_cast(*child_node).local_name(); + + // 5. If child is not a string, return true. + if (!child.has()) + return true; + 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 (parent.has>() && is(*parent.get>())) { + auto& parent_html_element = static_cast(*parent.get>()); + + // 1. If child is "a", and parent or some ancestor of parent is an a, return false. + if (child_local_name == HTML::TagNames::a) { + DOM::Node* ancestor = &parent_html_element; + do { + if (is(ancestor) && static_cast(*ancestor).local_name() == HTML::TagNames::a) + return false; + ancestor = ancestor->parent(); + } while (ancestor); + } + + // 2. If child is a prohibited paragraph child name and parent or some ancestor of parent is an element with + // inline contents, return false. + if (is_prohibited_paragraph_child_name(child_local_name)) { + DOM::Node* ancestor = &parent_html_element; + do { + if (is_element_with_inline_contents(*ancestor)) + return false; + ancestor = ancestor->parent(); + } while (ancestor); + } + + // 3. If child is "h1", "h2", "h3", "h4", "h5", or "h6", and parent or some ancestor of parent is an HTML + // element with local name "h1", "h2", "h3", "h4", "h5", or "h6", return false. + if (is_heading(child_local_name)) { + DOM::Node* ancestor = &parent_html_element; + do { + if (is(*ancestor) && is_heading(static_cast(*ancestor).local_name())) + return false; + ancestor = ancestor->parent(); + } while (ancestor); + } + + // 4. Let parent be the local name of parent. + parent = parent_html_element.local_name(); + } + + // 7. If parent is an Element or DocumentFragment, return true. + if (parent.has>()) { + auto parent_element = parent.get>(); + if (is(*parent_element) || is(*parent_element)) + return true; + } + + // 8. If parent is not a string, return false. + if (!parent.has()) + return false; + parent_local_name = parent.get(); + + // 9. If parent is on the left-hand side of an entry on the following list, then return true if child is listed on + // the right-hand side of that entry, and false otherwise. + + // * colgroup: col + if (parent_local_name == HTML::TagNames::colgroup) + return child_local_name == HTML::TagNames::col; + + // * table: caption, col, colgroup, tbody, td, tfoot, th, thead, tr + if (parent_local_name == HTML::TagNames::table) { + return child_local_name.is_one_of( + HTML::TagNames::caption, + HTML::TagNames::col, + HTML::TagNames::colgroup, + HTML::TagNames::tbody, + HTML::TagNames::td, + HTML::TagNames::tfoot, + HTML::TagNames::th, + HTML::TagNames::thead, + HTML::TagNames::tr); + } + + // * tbody, tfoot, thead: td, th, tr + if (parent_local_name.is_one_of(HTML::TagNames::tbody, HTML::TagNames::tfoot, HTML::TagNames::thead)) + return child_local_name.is_one_of(HTML::TagNames::td, HTML::TagNames::th, HTML::TagNames::tr); + + // * tr: td, th + if (parent_local_name == HTML::TagNames::tr) + return child_local_name.is_one_of(HTML::TagNames::td, HTML::TagNames::th); + + // * dl: dt, dd + if (parent_local_name == HTML::TagNames::dl) + return child_local_name.is_one_of(HTML::TagNames::dt, HTML::TagNames::dd); + + // * dir, ol, ul: dir, li, ol, ul + if (parent_local_name.is_one_of(HTML::TagNames::dir, HTML::TagNames::ol, HTML::TagNames::ul)) + return child_local_name.is_one_of(HTML::TagNames::dir, HTML::TagNames::li, HTML::TagNames::ol, HTML::TagNames::ul); + + // * hgroup: h1, h2, h3, h4, h5, h6 + if (parent_local_name == HTML::TagNames::hgroup) + return is_heading(child_local_name); + + // 10. If child is "body", "caption", "col", "colgroup", "frame", "frameset", "head", "html", "tbody", "td", + // "tfoot", "th", "thead", or "tr", return false. + if (child_local_name.is_one_of( + HTML::TagNames::body, + HTML::TagNames::caption, + HTML::TagNames::col, + HTML::TagNames::colgroup, + HTML::TagNames::frame, + HTML::TagNames::frameset, + HTML::TagNames::head, + HTML::TagNames::html, + HTML::TagNames::tbody, + HTML::TagNames::td, + HTML::TagNames::tfoot, + HTML::TagNames::th, + HTML::TagNames::thead, + HTML::TagNames::tr)) + return false; + + // 11. If child is "dd" or "dt" and parent is not "dl", return false. + if (child_local_name.is_one_of(HTML::TagNames::dd, HTML::TagNames::dt) && parent_local_name != HTML::TagNames::dl) + return false; + + // 12. If child is "li" and parent is not "ol" or "ul", return false. + if (child_local_name == HTML::TagNames::li && parent_local_name != HTML::TagNames::ol && parent_local_name != HTML::TagNames::ul) + return false; + + // 13. If parent is on the left-hand side of an entry on the following list and child is listed on the right-hand + // side of that entry, return false. + + // * a: a + if (parent_local_name == HTML::TagNames::a && child_local_name == HTML::TagNames::a) + return false; + + // * dd, dt: dd, dt + if (parent_local_name.is_one_of(HTML::TagNames::dd, HTML::TagNames::dt) + && child_local_name.is_one_of(HTML::TagNames::dd, HTML::TagNames::dt)) + return false; + + // * h1, h2, h3, h4, h5, h6: h1, h2, h3, h4, h5, h6 + if (is_heading(parent_local_name) && is_heading(child_local_name)) + return false; + + // * li: li + if (parent_local_name == HTML::TagNames::li && child_local_name == HTML::TagNames::li) + return false; + + // * nobr: nobr + if (parent_local_name == HTML::TagNames::nobr && child_local_name == HTML::TagNames::nobr) + return false; + + // * All names of an element with inline contents: all prohibited paragraph child names + if (is_name_of_an_element_with_inline_contents(parent_local_name) && is_prohibited_paragraph_child_name(child_local_name)) + return false; + + // * td, th: caption, col, colgroup, tbody, td, tfoot, th, thead, tr + if (parent_local_name.is_one_of(HTML::TagNames::td, HTML::TagNames::th) + && child_local_name.is_one_of( + HTML::TagNames::caption, + HTML::TagNames::col, + HTML::TagNames::colgroup, + HTML::TagNames::tbody, + HTML::TagNames::td, + HTML::TagNames::tfoot, + HTML::TagNames::th, + HTML::TagNames::thead, + HTML::TagNames::tr)) + return false; + + // 14. Return true. + return true; +} + +// https://w3c.github.io/editing/docs/execCommand/#block-boundary-point +bool is_block_boundary_point(GC::Ref node, u32 offset) +{ + // A boundary point is a block boundary point if it is either a block start point or a block end point. + return is_block_start_point(node, offset) || is_block_end_point(node, offset); +} + +// https://w3c.github.io/editing/docs/execCommand/#block-end-point +bool is_block_end_point(GC::Ref node, u32 offset) +{ + // A boundary point (node, offset) is a block end point if either node's parent is null and + // offset is node's length; + if (!node->parent() && offset == node->length()) + return true; + + // or node has a child with index offset, and that child is a visible block node. + auto offset_child = node->child_at_index(offset); + return offset_child && is_visible_node(*offset_child) && is_block_node(*offset_child); +} + +// https://w3c.github.io/editing/docs/execCommand/#block-node +bool is_block_node(GC::Ref node) +{ + // A block node is either an Element whose "display" property does not have resolved value + // "inline" or "inline-block" or "inline-table" or "none", or a document, or a DocumentFragment. + if (is(*node) || is(*node)) + return true; + + auto layout_node = node->layout_node(); + if (!layout_node) + return false; + + auto display = layout_node->display(); + return is(*node) + && !(display.is_inline_outside() && (display.is_flow_inside() || display.is_flow_root_inside() || display.is_table_inside())) + && !display.is_none(); +} + +// https://w3c.github.io/editing/docs/execCommand/#block-start-point +bool is_block_start_point(GC::Ref node, u32 offset) +{ + // A boundary point (node, offset) is a block start point if either node's parent is null and + // offset is zero; + if (!node->parent() && offset == 0) + return true; + + // or node has a child with index offset − 1, and that child is either a visible block node or a + // visible br. + auto offset_minus_one_child = node->child_at_index(offset - 1); + if (!offset_minus_one_child) + return false; + return is_visible_node(*offset_minus_one_child) + && (is_block_node(*offset_minus_one_child) || is(*offset_minus_one_child)); +} + +// https://w3c.github.io/editing/docs/execCommand/#collapsed-whitespace-node +bool is_collapsed_whitespace_node(GC::Ref node) +{ + // 1. If node is not a whitespace node, return false. + if (!is_whitespace_node(node)) + return false; + + // 2. If node's data is the empty string, return true. + auto node_data = node->text_content(); + if (!node_data.has_value() || node_data->is_empty()) + return true; + + // 3. Let ancestor be node's parent. + GC::Ptr ancestor = node->parent(); + + // 4. If ancestor is null, return true. + if (!ancestor) + return true; + + // 5. If the "display" property of some ancestor of node has resolved value "none", return true. + if (ancestor->layout_node() && ancestor->layout_node()->display().is_none()) + return true; + + // 6. While ancestor is not a block node and its parent is not null, set ancestor to its parent. + while (!is_block_node(*ancestor) && ancestor->parent()) + ancestor = ancestor->parent(); + + // 7. Let reference be node. + auto reference = node; + + // 8. While reference is a descendant of ancestor: + while (reference->is_descendant_of(*ancestor)) { + // 1. Let reference be the node before it in tree order. + reference = *reference->previous_in_pre_order(); + + // 2. If reference is a block node or a br, return true. + if (is_block_node(reference) || is(*reference)) + return true; + + // 3. If reference is a Text node that is not a whitespace node, or is an img, break from + // this loop. + if ((is(*reference) && !is_whitespace_node(reference)) || is(*reference)) + break; + } + + // 9. Let reference be node. + reference = node; + + // 10. While reference is a descendant of ancestor: + while (reference->is_descendant_of(*ancestor)) { + // 1. Let reference be the node after it in tree order, or null if there is no such node. + reference = *reference->next_in_pre_order(); + + // 2. If reference is a block node or a br, return true. + if (is_block_node(reference) || is(*reference)) + return true; + + // 3. If reference is a Text node that is not a whitespace node, or is an img, break from + // this loop. + if ((is(*reference) && !is_whitespace_node(reference)) || is(*reference)) + break; + } + + // 11. Return false. + return false; +} + +// https://html.spec.whatwg.org/multipage/interaction.html#editing-host +bool is_editing_host(GC::Ref node) +{ + // An editing host is either an HTML element with its contenteditable attribute in the true + // state or plaintext-only state, or a child HTML element of a Document whose design mode + // enabled is true. + // FIXME: check contenteditable "plaintext-only" + if (!is(*node)) + return false; + auto const& html_element = static_cast(*node); + return html_element.content_editable() == "true"sv || node->document().design_mode_enabled_state(); +} + +// https://w3c.github.io/editing/docs/execCommand/#element-with-inline-contents +bool is_element_with_inline_contents(GC::Ref node) +{ + // An element with inline contents is an HTML element whose local name is a name of an element with inline contents. + return is(*node) + && is_name_of_an_element_with_inline_contents(static_cast(*node).local_name()); +} + +// https://w3c.github.io/editing/docs/execCommand/#extraneous-line-break +bool is_extraneous_line_break(GC::Ref node) +{ + // An extraneous line break is a br + if (!is(*node)) + return false; + + // ...except that a br that is the sole child of an li is not extraneous. + auto parent = node->parent(); + if (parent && static_cast(*parent).local_name() == HTML::TagNames::li && parent->child_count() == 1) + return false; + + // FIXME: ...that has no visual effect, in that removing it from the DOM + // would not change layout, + + return false; +} + +// https://w3c.github.io/editing/docs/execCommand/#in-the-same-editing-host +bool is_in_same_editing_host(GC::Ref node_a, GC::Ref node_b) +{ + // Two nodes are in the same editing host if the editing host of the first is non-null and the + // same as the editing host of the second. + auto editing_host_a = editing_host_of_node(node_a); + auto editing_host_b = editing_host_of_node(node_b); + return editing_host_a && editing_host_a == editing_host_b; +} + +// https://w3c.github.io/editing/docs/execCommand/#inline-node +bool is_inline_node(GC::Ref node) +{ + // An inline node is a node that is not a block node. + return !is_block_node(node); +} + +// https://w3c.github.io/editing/docs/execCommand/#invisible +bool is_invisible_node(GC::Ref node) +{ + // Something is invisible if it is a node that is not visible. + return !is_visible_node(node); +} + +// https://w3c.github.io/editing/docs/execCommand/#name-of-an-element-with-inline-contents +bool is_name_of_an_element_with_inline_contents(FlyString const& local_name) +{ + // A name of an element with inline contents is "a", "abbr", "b", "bdi", "bdo", "cite", "code", "dfn", "em", "h1", + // "h2", "h3", "h4", "h5", "h6", "i", "kbd", "mark", "p", "pre", "q", "rp", "rt", "ruby", "s", "samp", "small", + // "span", "strong", "sub", "sup", "u", "var", "acronym", "listing", "strike", "xmp", "big", "blink", "font", + // "marquee", "nobr", or "tt". + return local_name.is_one_of( + HTML::TagNames::a, + HTML::TagNames::abbr, + HTML::TagNames::b, + HTML::TagNames::bdi, + HTML::TagNames::bdo, + HTML::TagNames::cite, + HTML::TagNames::code, + HTML::TagNames::dfn, + HTML::TagNames::em, + HTML::TagNames::h1, + HTML::TagNames::h2, + HTML::TagNames::h3, + HTML::TagNames::h4, + HTML::TagNames::h5, + HTML::TagNames::h6, + HTML::TagNames::i, + HTML::TagNames::kbd, + HTML::TagNames::mark, + HTML::TagNames::p, + HTML::TagNames::pre, + HTML::TagNames::q, + HTML::TagNames::rp, + HTML::TagNames::rt, + HTML::TagNames::ruby, + HTML::TagNames::s, + HTML::TagNames::samp, + HTML::TagNames::small, + HTML::TagNames::span, + HTML::TagNames::strong, + HTML::TagNames::sub, + HTML::TagNames::sup, + HTML::TagNames::u, + HTML::TagNames::var, + HTML::TagNames::acronym, + HTML::TagNames::listing, + HTML::TagNames::strike, + HTML::TagNames::xmp, + HTML::TagNames::big, + HTML::TagNames::blink, + HTML::TagNames::font, + HTML::TagNames::marquee, + HTML::TagNames::nobr, + HTML::TagNames::tt); +} + +// https://w3c.github.io/editing/docs/execCommand/#prohibited-paragraph-child-name +bool is_prohibited_paragraph_child_name(FlyString const& local_name) +{ + // A prohibited paragraph child name is "address", "article", "aside", "blockquote", "caption", "center", "col", + // "colgroup", "dd", "details", "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", + // "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "li", "listing", "menu", "nav", "ol", "p", + // "plaintext", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", or "xmp". + return local_name.is_one_of( + HTML::TagNames::address, + HTML::TagNames::article, + HTML::TagNames::aside, + HTML::TagNames::blockquote, + HTML::TagNames::caption, + HTML::TagNames::center, + HTML::TagNames::col, + HTML::TagNames::colgroup, + HTML::TagNames::dd, + HTML::TagNames::details, + HTML::TagNames::dir, + HTML::TagNames::div, + HTML::TagNames::dl, + HTML::TagNames::dt, + HTML::TagNames::fieldset, + HTML::TagNames::figcaption, + HTML::TagNames::figure, + HTML::TagNames::footer, + HTML::TagNames::form, + HTML::TagNames::h1, + HTML::TagNames::h2, + HTML::TagNames::h3, + HTML::TagNames::h4, + HTML::TagNames::h5, + HTML::TagNames::h6, + HTML::TagNames::header, + HTML::TagNames::hgroup, + HTML::TagNames::hr, + HTML::TagNames::li, + HTML::TagNames::listing, + HTML::TagNames::menu, + HTML::TagNames::nav, + HTML::TagNames::ol, + HTML::TagNames::p, + HTML::TagNames::plaintext, + HTML::TagNames::pre, + HTML::TagNames::section, + HTML::TagNames::summary, + HTML::TagNames::table, + HTML::TagNames::tbody, + HTML::TagNames::td, + HTML::TagNames::tfoot, + HTML::TagNames::th, + HTML::TagNames::thead, + HTML::TagNames::tr, + HTML::TagNames::ul, + HTML::TagNames::xmp); +} + +// https://w3c.github.io/editing/docs/execCommand/#visible +bool is_visible_node(GC::Ref node) +{ + // excluding any node with an inclusive ancestor Element whose "display" property has resolved + // value "none". + GC::Ptr inclusive_ancestor = node; + do { + auto* layout_node = inclusive_ancestor->layout_node(); + if (layout_node && layout_node->display().is_none()) + return false; + inclusive_ancestor = inclusive_ancestor->parent(); + } while (inclusive_ancestor); + + // Something is visible if it is a node that either is a block node, + if (is_block_node(node)) + return true; + + // or a Text node that is not a collapsed whitespace node, + if (is(*node) && !is_collapsed_whitespace_node(node)) + return true; + + // or an img, + if (is(*node)) + return true; + + // or a br that is not an extraneous line break, + if (is(*node) && !is_extraneous_line_break(node)) + return true; + + // or any node with a visible descendant; + // NOTE: We call into is_visible_node() recursively, so check children instead of descendants. + bool has_visible_child_node = false; + node->for_each_child([&](DOM::Node& child_node) { + if (is_visible_node(child_node)) { + has_visible_child_node = true; + return IterationDecision::Break; + } + return IterationDecision::Continue; + }); + return has_visible_child_node; +} + +// https://w3c.github.io/editing/docs/execCommand/#whitespace-node +bool is_whitespace_node(GC::Ref node) +{ + // NOTE: All constraints below check that node is a Text node + if (!is(*node)) + return false; + + // A whitespace node is either a Text node whose data is the empty string; + auto& character_data = static_cast(*node); + if (character_data.data().is_empty()) + return true; + + // NOTE: All constraints below require a parent Element with a resolved value for "white-space" + GC::Ptr parent = node->parent(); + if (!is(parent.ptr())) + return false; + auto* layout_node = parent->layout_node(); + if (!layout_node) + return false; + auto white_space = layout_node->computed_values().white_space(); + + // or a Text node whose data consists only of one or more tabs (0x0009), line feeds (0x000A), + // carriage returns (0x000D), and/or spaces (0x0020), and whose parent is an Element whose + // resolved value for "white-space" is "normal" or "nowrap"; + auto is_tab_lf_cr_or_space = [](u32 codepoint) { + return codepoint == '\t' || codepoint == '\n' || codepoint == '\r' || codepoint == ' '; + }; + auto code_points = character_data.data().code_points(); + if (all_of(code_points, is_tab_lf_cr_or_space) && (white_space == CSS::WhiteSpace::Normal || white_space == CSS::WhiteSpace::Nowrap)) + return true; + + // or a Text node whose data consists only of one or more tabs (0x0009), carriage returns + // (0x000D), and/or spaces (0x0020), and whose parent is an Element whose resolved value for + // "white-space" is "pre-line". + auto is_tab_cr_or_space = [](u32 codepoint) { + return codepoint == '\t' || codepoint == '\r' || codepoint == ' '; + }; + if (all_of(code_points, is_tab_cr_or_space) && white_space == CSS::WhiteSpace::PreLine) + return true; + + return false; +} + +// https://w3c.github.io/editing/docs/execCommand/#preserving-ranges +void move_node_preserving_ranges(GC::Ref node, GC::Ref new_parent, u32 new_index) +{ + // To move a node to a new location, preserving ranges, remove the node from its original parent + // (if any), then insert it in the new location. In doing so, follow these rules instead of + // those defined by the insert and remove algorithms: + + // FIXME: Currently this is a simple range-destroying move. Implement "follow these rules" as + // described above. + + // 1. Let node be the moved node, old parent and old index be the old parent (which may be null) + // and index, and new parent and new index be the new parent and index. + auto* old_parent = node->parent(); + [[maybe_unused]] auto old_index = node->index(); + if (old_parent) + node->remove(); + + auto* new_next_sibling = new_parent->child_at_index(new_index); + new_parent->insert_before(node, new_next_sibling); + + // FIXME: 2. If a boundary point's node is the same as or a descendant of node, leave it unchanged, so + // it moves to the new location. + + // FIXME: 3. If a boundary point's node is new parent and its offset is greater than new index, add one + // to its offset. + + // FIXME: 4. If a boundary point's node is old parent and its offset is old index or old index + 1, set + // its node to new parent and add new index − old index to its offset. + + // FIXME: 5. If a boundary point's node is old parent and its offset is greater than old index + 1, + // subtract one from its offset. +} + +// https://w3c.github.io/editing/docs/execCommand/#normalize-sublists +void normalize_sublists_in_node(GC::Ref item) +{ + // 1. If item is not an li or it is not editable or its parent is not editable, abort these + // steps. + if (item->local_name() != HTML::TagNames::li || !item->is_editable() || !item->parent()->is_editable()) + return; + + // 2. Let new item be null. + GC::Ptr new_item; + + // 3. While item has an ol or ul child: + while (item->has_child_of_type() || item->has_child_of_type()) { + // 1. Let child be the last child of item. + GC::Ref child = *item->last_child(); + + // 2. If child is an ol or ul, or new item is null and child is a Text node whose data + // consists of zero of more space characters: + auto child_text = child->text_content(); + auto text_is_all_whitespace = child_text.has_value() && all_of(child_text.value().bytes_as_string_view(), Infra::is_ascii_whitespace); + if ((is(*child) || is(*child)) + || (!new_item && is(*child) && text_is_all_whitespace)) { + // 1. Set new item to null. + new_item = {}; + + // 2. Insert child into the parent of item immediately following item, preserving + // ranges. + move_node_preserving_ranges(child, *item->parent(), item->index()); + } + + // 3. Otherwise: + else { + // 1. If new item is null, let new item be the result of calling createElement("li") on + // the ownerDocument of item, then insert new item into the parent of item + // immediately after item. + if (!new_item) { + new_item = MUST(DOM::create_element(*item->owner_document(), HTML::TagNames::li, Namespace::HTML)); + item->parent()->insert_before(*new_item, item->next_sibling()); + } + + // 2. Insert child into new item as its first child, preserving ranges. + move_node_preserving_ranges(child, *new_item, 0); + } + } +} + +// https://w3c.github.io/editing/docs/execCommand/#precedes-a-line-break +bool precedes_a_line_break(GC::Ref node) +{ + // 1. Let offset be node's length. + auto offset = node->length(); + + // 2. While (node, offset) is not a block boundary point: + while (!is_block_boundary_point(node, offset)) { + // 1. If node has a visible child with index offset, return false. + auto* offset_child = node->child_at_index(offset); + if (offset_child && is_visible_node(*offset_child)) + return false; + + // 2. If offset is node's length or node has no children, set offset to one plus node's + // index, then set node to its parent. + if (offset == node->length() || node->child_count() == 0) { + offset = node->index() + 1; + node = *node->parent(); + } + + // 3. Otherwise, set node to its child with index offset and set offset to zero. + else { + node = *node->child_at_index(offset); + offset = 0; + } + } + + // 3. Return true; + return true; +} + +// https://w3c.github.io/editing/docs/execCommand/#record-the-values +Vector record_the_values_of_nodes(Vector> const& node_list) +{ + // 1. Let values be a list of (node, command, specified command value) triples, initially empty. + Vector values; + + // 2. For each node in node list, for each command in the list "subscript", "bold", "fontName", + // "fontSize", "foreColor", "hiliteColor", "italic", "strikethrough", and "underline" in that + // order: + Array const commands = { CommandNames::subscript, CommandNames::bold, CommandNames::fontName, + CommandNames::fontSize, CommandNames::foreColor, CommandNames::hiliteColor, CommandNames::italic, + CommandNames::strikethrough, CommandNames::underline }; + for (auto node : node_list) { + for (auto command : commands) { + // 1. Let ancestor equal node. + auto ancestor = node; + + // 2. If ancestor is not an Element, set it to its parent. + if (!is(*ancestor)) + ancestor = *ancestor->parent(); + + // 3. While ancestor is an Element and its specified command value for command is null, set + // it to its parent. + while (is(*ancestor) && !specified_command_value(static_cast(*ancestor), command).has_value()) + ancestor = *ancestor->parent(); + + // 4. If ancestor is an Element, add (node, command, ancestor's specified command value for + // command) to values. Otherwise add (node, command, null) to values. + if (is(*ancestor)) + values.empend(*node, command, specified_command_value(static_cast(*ancestor), command)); + else + values.empend(*node, command, OptionalNone {}); + } + } + + // 3. Return values. + return values; +} + +// https://w3c.github.io/editing/docs/execCommand/#remove-extraneous-line-breaks-at-the-end-of +void remove_extraneous_line_breaks_at_the_end_of_node(GC::Ref node) +{ + // 1. Let ref be node. + GC::Ptr ref = node; + + // 2. While ref has children, set ref to its lastChild. + while (ref->child_count() > 0) + ref = ref->last_child(); + + // 3. While ref is invisible but not an extraneous line break, and ref does not equal node, set + // ref to the node before it in tree order. + while (is_invisible_node(*ref) + && !is_extraneous_line_break(*ref) + && ref != node) { + ref = ref->previous_in_pre_order(); + } + + // 4. If ref is an editable extraneous line break: + if (ref->is_editable() && is_extraneous_line_break(*ref)) { + // 1. While ref's parent is editable and invisible, set ref to its parent. + while (ref->parent()->is_editable() && is_invisible_node(*ref->parent())) + ref = ref->parent(); + + // 2. Remove ref from its parent. + ref->remove(); + } +} + +// https://w3c.github.io/editing/docs/execCommand/#remove-extraneous-line-breaks-before +void remove_extraneous_line_breaks_before_node(GC::Ref node) +{ + // 1. Let ref be the previousSibling of node. + GC::Ptr ref = node->previous_sibling(); + + // 2. If ref is null, abort these steps. + if (!ref) + return; + + // 3. While ref has children, set ref to its lastChild. + while (ref->child_count() > 0) + ref = ref->last_child(); + + // 4. While ref is invisible but not an extraneous line break, and ref does not equal node's + // parent, set ref to the node before it in tree order. + while (is_invisible_node(*ref) + && !is_extraneous_line_break(*ref) + && ref != node->parent()) { + ref = ref->previous_in_pre_order(); + } + + // 5. If ref is an editable extraneous line break, remove it from its parent. + if (ref->is_editable() && is_extraneous_line_break(*ref)) + ref->remove(); +} + +// https://w3c.github.io/editing/docs/execCommand/#remove-extraneous-line-breaks-from +void remove_extraneous_line_breaks_from_a_node(GC::Ref node) +{ + // To remove extraneous line breaks from a node, first remove extraneous line breaks before it, + // then remove extraneous line breaks at the end of it. + remove_extraneous_line_breaks_before_node(node); + remove_extraneous_line_breaks_at_the_end_of_node(node); +} + +// https://w3c.github.io/editing/docs/execCommand/#preserving-its-descendants +void remove_node_preserving_its_descendants(GC::Ref node) +{ + // To remove a node node while preserving its descendants, split the parent of node's children + // if it has any. + if (node->child_count() > 0) { + Vector> children; + children.ensure_capacity(node->child_count()); + for (auto* child = node->first_child(); child; child = child->next_sibling()) + children.append(*child); + split_the_parent_of_nodes(move(children)); + return; + } + + // If it has no children, instead remove it from its parent. + node->remove(); +} + +// https://w3c.github.io/editing/docs/execCommand/#restore-the-values +void restore_the_values_of_nodes(Vector const& values) +{ + // 1. For each (node, command, value) triple in values: + for (auto& recorded_node_value : values) { + // 1. Let ancestor equal node. + GC::Ptr ancestor = recorded_node_value.node; + + // 2. If ancestor is not an Element, set it to its parent. + if (!is(*ancestor)) + ancestor = *ancestor->parent(); + + // 3. While ancestor is an Element and its specified command value for command is null, set it to its parent. + auto const& command = recorded_node_value.command; + while (is(*ancestor) && !specified_command_value(static_cast(*ancestor), command).has_value()) + ancestor = *ancestor->parent(); + + // FIXME: 4. If value is null and ancestor is an Element, push down values on node for command, with new value null. + + // FIXME: 5. Otherwise, if ancestor is an Element and its specified command value for command is not equivalent to + // value, or if ancestor is not an Element and value is not null, force the value of command to value on + // node. + } +} + +// https://w3c.github.io/editing/docs/execCommand/#set-the-tag-name +GC::Ref set_the_tag_name(GC::Ref element, FlyString const& new_name) +{ + // 1. If element is an HTML element with local name equal to new name, return element. + if (is(*element) && static_cast(element).local_name() == new_name) + return element; + + // 2. If element's parent is null, return element. + if (!element->parent()) + return element; + + // 3. Let replacement element be the result of calling createElement(new name) on the ownerDocument of element. + auto replacement_element = MUST(element->owner_document()->create_element(new_name.to_string(), DOM::ElementCreationOptions {})); + + // 4. Insert replacement element into element's parent immediately before element. + element->parent()->insert_before(replacement_element, element); + + // 5. Copy all attributes of element to replacement element, in order. + element->for_each_attribute([&replacement_element](FlyString const& name, String const& value) { + MUST(replacement_element->set_attribute(name, value)); + }); + + // 6. While element has children, append the first child of element as the last child of replacement element, preserving ranges. + while (element->has_children()) + move_node_preserving_ranges(*element->first_child(), *replacement_element, replacement_element->child_count()); + + // 7. Remove element from its parent. + element->remove(); + + // 8. Return replacement element. + return replacement_element; +} + +// https://w3c.github.io/editing/docs/execCommand/#specified-command-value +Optional specified_command_value(GC::Ref element, FlyString const& command) +{ + // 1. If command is "backColor" or "hiliteColor" and the Element's display property does not have resolved value "inline", return null. + auto layout_node = element->layout_node(); + if ((command == CommandNames::backColor || command == CommandNames::hiliteColor) && layout_node) { + if (layout_node->computed_values().display().is_inline_outside()) + return {}; + } + + // 2. If command is "createLink" or "unlink": + if (command == CommandNames::createLink || command == CommandNames::unlink) { + // 1. If element is an a element and has an href attribute, return the value of that attribute. + auto href_attribute = element->get_attribute(HTML::AttributeNames::href); + if (href_attribute.has_value()) + return href_attribute.release_value(); + + // 2. Return null. + return {}; + } + + // 3. If command is "subscript" or "superscript": + if (command == CommandNames::subscript || command == CommandNames::superscript) { + // 1. If element is a sup, return "superscript". + if (element->local_name() == HTML::TagNames::sup) + return "superscript"_string; + + // 2. If element is a sub, return "subscript". + if (element->local_name() == HTML::TagNames::sub) + return "subscript"_string; + + // 3. Return null. + return {}; + } + + // FIXME: 4. If command is "strikethrough", and element has a style attribute set, and that attribute sets "text-decoration": + if (false) { + // FIXME: 1. If element's style attribute sets "text-decoration" to a value containing "line-through", return "line-through". + + // 2. Return null. + return {}; + } + + // 5. If command is "strikethrough" and element is an s or strike element, return "line-through". + if (command == CommandNames::strikethrough && (element->local_name() == HTML::TagNames::s || element->local_name() == HTML::TagNames::strike)) + return "line-through"_string; + + // FIXME: 6. If command is "underline", and element has a style attribute set, and that attribute sets "text-decoration": + if (false) { + // FIXME: 1. If element's style attribute sets "text-decoration" to a value containing "underline", return "underline". + + // 2. Return null. + return {}; + } + + // 7. If command is "underline" and element is a u element, return "underline". + if (command == CommandNames::underline && element->local_name() == HTML::TagNames::u) + return "underline"_string; + + // FIXME: 8. Let property be the relevant CSS property for command. + + // FIXME: 9. If property is null, return null. + + // FIXME: 10. If element has a style attribute set, and that attribute has the effect of setting property, return the value + // that it sets property to. + + // FIXME: 11. If element is a font element that has an attribute whose effect is to create a presentational hint for + // property, return the value that the hint sets property to. (For a size of 7, this will be the non-CSS value + // "xxx-large".) + + // FIXME: 12. If element is in the following list, and property is equal to the CSS property name listed for it, return the + // string listed for it. + // * b, strong: font-weight: "bold" + // * i, em: font-style: "italic" + + // 13. Return null. + return {}; +} + +// https://w3c.github.io/editing/docs/execCommand/#split-the-parent +void split_the_parent_of_nodes(Vector> const& nodes) +{ + VERIFY(nodes.size() > 0); + + // 1. Let original parent be the parent of the first member of node list. + GC::Ref first_node = *nodes.first(); + GC::Ref last_node = *nodes.last(); + GC::Ref original_parent = *first_node->parent(); + + // 2. If original parent is not editable or its parent is null, do nothing and abort these + // steps. + if (!original_parent->is_editable() || !original_parent->parent()) + return; + + // 3. If the first child of original parent is in node list, remove extraneous line breaks + // before original parent. + GC::Ref first_child = *original_parent->first_child(); + auto first_child_in_nodes_list = nodes.contains_slow(first_child); + if (first_child_in_nodes_list) + remove_extraneous_line_breaks_before_node(original_parent); + + // 4. If the first child of original parent is in node list, and original parent follows a line + // break, set follows line break to true. Otherwise, set follows line break to false. + auto follows_line_break = first_child_in_nodes_list && follows_a_line_break(original_parent); + + // 5. If the last child of original parent is in node list, and original parent precedes a line + // break, set precedes line break to true. Otherwise, set precedes line break to false. + GC::Ref last_child = *original_parent->last_child(); + bool last_child_in_nodes_list = nodes.contains_slow(last_child); + auto precedes_line_break = last_child_in_nodes_list && precedes_a_line_break(original_parent); + + // 6. If the first child of original parent is not in node list, but its last child is: + GC::Ref parent_of_original_parent = *original_parent->parent(); + auto original_parent_index = original_parent->index(); + auto& document = original_parent->document(); + if (!first_child_in_nodes_list && last_child_in_nodes_list) { + // 1. For each node in node list, in reverse order, insert node into the parent of original + // parent immediately after original parent, preserving ranges. + for (auto node : nodes.in_reverse()) + move_node_preserving_ranges(node, parent_of_original_parent, original_parent_index + 1); + + // 2. If precedes line break is true, and the last member of node list does not precede a + // line break, call createElement("br") on the context object and insert the result + // immediately after the last member of node list. + if (precedes_line_break && !precedes_a_line_break(last_node)) { + auto br_element = MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)); + MUST(last_node->parent()->append_child(br_element)); + } + + // 3. Remove extraneous line breaks at the end of original parent. + remove_extraneous_line_breaks_at_the_end_of_node(original_parent); + + // 4. Abort these steps. + return; + } + + // 7. If the first child of original parent is not in node list: + if (!first_child_in_nodes_list) { + // 1. Let cloned parent be the result of calling cloneNode(false) on original parent. + auto cloned_parent = MUST(original_parent->clone_node(nullptr, false)); + + // 2. If original parent has an id attribute, unset it. + auto& original_parent_element = static_cast(*original_parent); + if (original_parent_element.has_attribute(HTML::AttributeNames::id)) + original_parent_element.remove_attribute(HTML::AttributeNames::id); + + // 3. Insert cloned parent into the parent of original parent immediately before original + // parent. + original_parent->parent()->insert_before(cloned_parent, original_parent); + + // 4. While the previousSibling of the first member of node list is not null, append the + // first child of original parent as the last child of cloned parent, preserving ranges. + while (first_node->previous_sibling()) + move_node_preserving_ranges(*original_parent->first_child(), cloned_parent, cloned_parent->child_count()); + } + + // 8. For each node in node list, insert node into the parent of original parent immediately + // before original parent, preserving ranges. + for (auto node : nodes) + move_node_preserving_ranges(node, parent_of_original_parent, original_parent_index - 1); + + // 9. If follows line break is true, and the first member of node list does not follow a line + // break, call createElement("br") on the context object and insert the result immediately + // before the first member of node list. + if (follows_line_break && !follows_a_line_break(first_node)) { + auto br_element = MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)); + first_node->parent()->insert_before(br_element, first_node); + } + + // 10. If the last member of node list is an inline node other than a br, and the first child of + // original parent is a br, and original parent is not an inline node, remove the first + // child of original parent from original parent. + if (is_inline_node(last_node) && !is(*last_node) && is(*first_child) && !is_inline_node(original_parent)) + first_child->remove(); + + // 11. If original parent has no children: + if (original_parent->child_count() == 0) { + // 1. Remove original parent from its parent. + original_parent->remove(); + + // 2. If precedes line break is true, and the last member of node list does not precede a + // line break, call createElement("br") on the context object and insert the result + // immediately after the last member of node list. + if (precedes_line_break && !precedes_a_line_break(last_node)) { + auto br_element = MUST(DOM::create_element(document, HTML::TagNames::br, Namespace::HTML)); + last_node->parent()->insert_before(br_element, last_node->next_sibling()); + } + } + + // 12. Otherwise, remove extraneous line breaks before original parent. + else { + remove_extraneous_line_breaks_before_node(original_parent); + } + + // 13. If node list's last member's nextSibling is null, but its parent is not null, remove + // extraneous line breaks at the end of node list's last member's parent. + if (!last_node->next_sibling() && last_node->parent()) + remove_extraneous_line_breaks_at_the_end_of_node(*last_node->parent()); +} + +} diff --git a/Libraries/LibWeb/Editing/Internal/Algorithms.h b/Libraries/LibWeb/Editing/Internal/Algorithms.h new file mode 100644 index 00000000000..b7ea4a61b6f --- /dev/null +++ b/Libraries/LibWeb/Editing/Internal/Algorithms.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024, Jelle Raaijmakers + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +namespace Web::Editing { + +// https://w3c.github.io/editing/docs/execCommand/#record-the-values +struct RecordedNodeValue { + GC::Ref node; + FlyString const& command; + Optional specified_command_value; +}; + +// Below algorithms are specified here: +// https://w3c.github.io/editing/docs/execCommand/#assorted-common-algorithms + +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&); +GC::Ptr editing_host_of_node(GC::Ref); +bool follows_a_line_break(GC::Ref); +bool is_allowed_child_of_node(Variant, FlyString> child, Variant, FlyString> parent); +bool is_block_boundary_point(GC::Ref, u32 offset); +bool is_block_end_point(GC::Ref, u32 offset); +bool is_block_node(GC::Ref); +bool is_block_start_point(GC::Ref, u32 offset); +bool is_collapsed_whitespace_node(GC::Ref); +bool is_editing_host(GC::Ref); +bool is_element_with_inline_contents(GC::Ref); +bool is_extraneous_line_break(GC::Ref); +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_prohibited_paragraph_child_name(FlyString const&); +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); +void normalize_sublists_in_node(GC::Ref); +bool precedes_a_line_break(GC::Ref); +Vector record_the_values_of_nodes(Vector> const&); +void remove_extraneous_line_breaks_at_the_end_of_node(GC::Ref); +void remove_extraneous_line_breaks_before_node(GC::Ref); +void remove_extraneous_line_breaks_from_a_node(GC::Ref); +void remove_node_preserving_its_descendants(GC::Ref); +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&); + +}