script: Split up contenteditable.rs into multiple files (#44110)

This file was getting way too big and too cluttered. Instead, split it
up into multiple files in a dedicated folder.

Part of #25005
Part of #43709

Testing: It compiles

Signed-off-by: Tim van der Lippe <tvanderlippe@gmail.com>
This commit is contained in:
Tim van der Lippe
2026-04-11 17:18:36 +02:00
committed by GitHub
parent 93c9918cd5
commit ec513ff851
11 changed files with 1831 additions and 1782 deletions

View File

@@ -13,7 +13,6 @@ use script_bindings::script_runtime::CanGc;
use servo_constellation_traits::ScriptToConstellationMessage;
use crate::dom::bindings::cell::DomRefCell;
use crate::dom::execcommand::contenteditable::ContentEditableRange;
use crate::dom::focusevent::FocusEventType;
use crate::dom::types::{Element, EventTarget, FocusEvent, HTMLElement, HTMLIFrameElement, Window};
use crate::dom::{Document, Event, EventBubbles, EventCancelable, Node, NodeTraits};

View File

@@ -7,9 +7,8 @@ use script_bindings::inheritance::Castable;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::bindings::codegen::Bindings::SelectionBinding::SelectionMethods;
use crate::dom::document::Document;
use crate::dom::execcommand::contenteditable::{
NodeExecCommandSupport, SelectionDeleteDirection, SelectionExecCommandSupport, split_the_parent,
};
use crate::dom::execcommand::contenteditable::node::split_the_parent;
use crate::dom::execcommand::contenteditable::selection::SelectionDeleteDirection;
use crate::dom::html::htmlanchorelement::HTMLAnchorElement;
use crate::dom::html::htmlbrelement::HTMLBRElement;
use crate::dom::html::htmlhrelement::HTMLHRElement;

View File

@@ -12,9 +12,6 @@ use crate::dom::bindings::codegen::Bindings::DocumentBinding::DocumentMethods;
use crate::dom::bindings::str::DOMString;
use crate::dom::document::Document;
use crate::dom::execcommand::basecommand::CommandName;
use crate::dom::execcommand::contenteditable::{
NodeExecCommandSupport, SelectionExecCommandSupport,
};
use crate::dom::selection::Selection;
use crate::script_runtime::CanGc;

View File

@@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use js::context::JSContext;
use crate::dom::bindings::codegen::Bindings::DocumentBinding::{
DocumentMethods, ElementCreationOptions,
};
use crate::dom::bindings::codegen::UnionTypes::StringOrElementCreationOptions;
use crate::dom::bindings::root::DomRoot;
use crate::dom::document::Document;
use crate::dom::element::Element;
impl Document {
pub(crate) fn create_element(&self, cx: &mut JSContext, name: &str) -> DomRoot<Element> {
let element_options =
StringOrElementCreationOptions::ElementCreationOptions(ElementCreationOptions {
is: None,
});
self.CreateElement(cx, name.into(), element_options)
.expect("Must always be able to create element")
}
}

View File

@@ -0,0 +1,227 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use html5ever::local_name;
use script_bindings::inheritance::Castable;
use style::attr::AttrValue;
use style::properties::{LonghandId, PropertyDeclaration, PropertyDeclarationId};
use style::values::specified::TextDecorationLine;
use style::values::specified::box_::DisplayOutside;
use crate::dom::bindings::inheritance::{ElementTypeId, HTMLElementTypeId, NodeTypeId};
use crate::dom::bindings::str::DOMString;
use crate::dom::element::Element;
use crate::dom::execcommand::basecommand::{CommandName, CssPropertyName};
use crate::dom::execcommand::commands::fontsize::font_size_to_css_font;
use crate::dom::html::htmlfontelement::HTMLFontElement;
use crate::dom::node::node::{Node, NodeTraits};
impl Element {
pub(crate) fn resolved_display_value(&self) -> Option<DisplayOutside> {
self.style().map(|style| style.get_box().display.outside())
}
/// <https://w3c.github.io/editing/docs/execCommand/#specified-command-value>
pub(crate) fn specified_command_value(&self, command: &CommandName) -> Option<DOMString> {
match command {
// Step 1. If command is "backColor" or "hiliteColor" and the Element's display property does not have resolved value "inline", return null.
CommandName::BackColor | CommandName::HiliteColor => {
// TODO
},
// Step 2. If command is "createLink" or "unlink":
CommandName::CreateLink | CommandName::Unlink => {
// TODO
},
// Step 3. If command is "subscript" or "superscript":
CommandName::Subscript | CommandName::Superscript => {
// TODO
},
CommandName::Strikethrough => {
// Step 4. If command is "strikethrough", and element has a style attribute set, and that attribute sets "text-decoration":
// TODO
// Step 5. If command is "strikethrough" and element is an s or strike element, return "line-through".
// TODO
},
CommandName::Underline => {
// Step 6. If command is "underline", and element has a style attribute set, and that attribute sets "text-decoration":
// TODO
// Step 7. If command is "underline" and element is a u element, return "underline".
// TODO
},
_ => {},
};
// Step 8. Let property be the relevant CSS property for command.
// Step 9. If property is null, return null.
let property = command.relevant_css_property()?;
// Step 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.
if let Some(value) = property.value_set_for_style(self) {
return Some(value);
}
// Step 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".)
if self.is::<HTMLFontElement>() {
if let Some(font_size) = self.get_attribute(&local_name!("size")) {
if let AttrValue::UInt(_, value) = *font_size.value() {
return Some(font_size_to_css_font(&value).into());
}
}
}
// Step 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.
let element_name = self.local_name();
match property {
CssPropertyName::FontWeight
if element_name == &local_name!("b") || element_name == &local_name!("strong") =>
{
Some("bold".into())
},
CssPropertyName::FontStyle
if element_name == &local_name!("i") || element_name == &local_name!("em") =>
{
Some("italic".into())
},
// Step 13. Return null.
_ => None,
}
}
/// <https://w3c.github.io/editing/docs/execCommand/#simple-modifiable-element>
pub(crate) fn is_simple_modifiable_element(&self) -> bool {
let attrs = self.attrs();
let attr_count = attrs.len();
let type_id = self.upcast::<Node>().type_id();
if matches!(
type_id,
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLAnchorElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLFontElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLSpanElement,
))
) {
// > It is an a, b, em, font, i, s, span, strike, strong, sub, sup, or u element with no attributes.
//
// TODO: All elements that are HTMLElement rather than a specific one
if attr_count == 0 {
return true;
}
// > It is an a, b, em, font, i, s, span, strike, strong, sub, sup, or u element
// > with exactly one attribute, which is style,
// > which sets no CSS properties (including invalid or unrecognized properties).
if attr_count == 1 &&
self.attrs().first().expect("Size is 1").local_name() == &local_name!("style")
{
let style_attribute = self.style_attribute().borrow();
if style_attribute.as_ref().is_some_and(|declarations| {
let document = self.owner_document();
let shared_lock = document.style_shared_lock();
let read_lock = shared_lock.read();
let style = declarations.read_with(&read_lock);
style.is_empty()
}) {
return true;
}
}
}
if attr_count != 1 {
return false;
}
let only_attribute = attrs.first().expect("Size is 1").local_name();
// > It is an a element with exactly one attribute, which is href.
if matches!(
type_id,
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLAnchorElement,
))
) {
return only_attribute == &local_name!("href");
}
// > It is a font element with exactly one attribute, which is either color, face, or size.
if matches!(
type_id,
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLFontElement,
))
) {
return only_attribute == &local_name!("color") ||
only_attribute == &local_name!("face") ||
only_attribute == &local_name!("size");
}
// > It is a b or strong element with exactly one attribute, which is style,
// > and the style attribute sets exactly one CSS property
// > (including invalid or unrecognized properties), which is "font-weight".
// TODO
// > It is an i or em element with exactly one attribute, which is style,
// > and the style attribute sets exactly one CSS property (including invalid or unrecognized properties),
// > which is "font-style".
// TODO
// > It is an a, font, or span element with exactly one attribute, which is style,
// > and the style attribute sets exactly one CSS property (including invalid or unrecognized properties),
// > and that property is not "text-decoration".
if matches!(
type_id,
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLAnchorElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLFontElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLSpanElement,
))
) {
if only_attribute != &local_name!("style") {
return false;
}
let style_attribute = self.style_attribute().borrow();
let Some(declarations) = style_attribute.as_ref() else {
return false;
};
let document = self.owner_document();
let shared_lock = document.style_shared_lock();
let read_lock = shared_lock.read();
let style = declarations.read_with(&read_lock);
if style.len() == 1 {
if let Some((text_decoration, _)) = style.get(PropertyDeclarationId::Longhand(
LonghandId::TextDecorationLine,
)) {
// > It is an a, font, s, span, strike, or u element with exactly one attribute,
// > which is style, and the style attribute sets exactly one CSS property
// > (including invalid or unrecognized properties), which is "text-decoration",
// > which is set to "line-through" or "underline" or "overline" or "none".
//
// TODO: Also the other element types
return matches!(
text_decoration,
PropertyDeclaration::TextDecorationLine(
TextDecorationLine::LINE_THROUGH |
TextDecorationLine::UNDERLINE |
TextDecorationLine::OVERLINE |
TextDecorationLine::NONE
)
);
} else {
// > It is an a, font, or span element with exactly one attribute, which is style,
// > and the style attribute sets exactly one CSS property (including invalid or unrecognized properties),
// > and that property is not "text-decoration".
return true;
}
}
}
false
}
}

View File

@@ -0,0 +1,220 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use html5ever::local_name;
use js::context::JSContext;
use script_bindings::inheritance::Castable;
use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
use crate::dom::bindings::codegen::Bindings::DocumentBinding::DocumentMethods;
use crate::dom::bindings::codegen::Bindings::HTMLElementBinding::HTMLElementMethods;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::bindings::codegen::Bindings::SelectionBinding::SelectionMethods;
use crate::dom::bindings::inheritance::{ElementTypeId, HTMLElementTypeId, NodeTypeId};
use crate::dom::bindings::root::DomRoot;
use crate::dom::element::Element;
use crate::dom::execcommand::basecommand::{CommandName, CssPropertyName};
use crate::dom::html::htmlanchorelement::HTMLAnchorElement;
use crate::dom::html::htmlelement::HTMLElement;
use crate::dom::html::htmlfontelement::HTMLFontElement;
use crate::dom::node::node::{Node, NodeTraits, ShadowIncluding};
use crate::dom::text::Text;
use crate::script_runtime::CanGc;
impl HTMLElement {
pub(crate) fn local_name(&self) -> &str {
self.upcast::<Element>().local_name()
}
/// <https://w3c.github.io/editing/docs/execCommand/#clear-the-value>
pub(crate) fn clear_the_value(&self, cx: &mut JSContext, command: &CommandName) {
// Step 1. Let command be the current command.
//
// Passed in as argument
let node = self.upcast::<Node>();
let element = self.upcast::<Element>();
// Step 2. If element is not editable, return the empty list.
if !node.is_editable() {
return;
}
// Step 3. If element's specified command value for command is null,
// return the empty list.
if element.specified_command_value(command).is_none() {
return;
}
// Step 4. If element is a simple modifiable element:
if element.is_simple_modifiable_element() {
// Step 4.1. Let children be the children of element.
// Step 4.2. For each child in children, insert child into element's parent immediately before element, preserving ranges.
let element_parent = node.GetParentNode().expect("Must always have a parent");
for child in node.children() {
if element_parent.InsertBefore(cx, &child, Some(node)).is_err() {
unreachable!("Must always be able to insert");
}
}
// Step 4.3. Remove element from its parent.
node.remove_self(cx);
// Step 4.4. Return children.
return;
}
match command {
// Step 5. If command is "strikethrough", and element has a style attribute
// that sets "text-decoration" to some value containing "line-through",
// delete "line-through" from the value.
CommandName::Strikethrough => {
let property = CssPropertyName::TextDecorationLine;
if property.value_for_element(cx, self) == "line-through" {
// TODO: Only remove line-through
property.remove_from_element(cx, self);
}
},
// Step 6. If command is "underline", and element has a style attribute that
// sets "text-decoration" to some value containing "underline", delete "underline" from the value.
CommandName::Underline => {
let property = CssPropertyName::TextDecorationLine;
if property.value_for_element(cx, self) == "underline" {
// TODO: Only remove underline
property.remove_from_element(cx, self);
}
},
_ => {},
}
// Step 7. If the relevant CSS property for command is not null,
// unset that property of element.
if let Some(property) = command.relevant_css_property() {
property.remove_from_element(cx, self);
}
// Step 8. If element is a font element:
if self.is::<HTMLFontElement>() {
match command {
// Step 8.1. If command is "foreColor", unset element's color attribute, if set.
CommandName::ForeColor => {
element.remove_attribute_by_name(&local_name!("color"), CanGc::from_cx(cx));
},
// Step 8.2. If command is "fontName", unset element's face attribute, if set.
CommandName::FontName => {
element.remove_attribute_by_name(&local_name!("face"), CanGc::from_cx(cx));
},
// Step 8.3. If command is "fontSize", unset element's size attribute, if set.
CommandName::FontSize => {
element.remove_attribute_by_name(&local_name!("size"), CanGc::from_cx(cx));
},
_ => {},
}
}
// Step 9. If element is an a element and command is "createLink" or "unlink",
// unset the href property of element.
if self.is::<HTMLAnchorElement>() &&
matches!(command, CommandName::CreateLink | CommandName::Unlink)
{
element.remove_attribute_by_name(&local_name!("href"), CanGc::from_cx(cx));
}
// Step 10. If element's specified command value for command is null,
// return the empty list.
if element.specified_command_value(command).is_none() {
// TODO
}
// Step 11. Set the tag name of element to "span",
// and return the one-node list consisting of the result.
// TODO
}
/// There is no specification for this implementation. Instead, it is
/// reverse-engineered based on the WPT test
/// /selection/contenteditable/initial-selection-on-focus.tentative.html
pub(crate) fn handle_focus_state_for_contenteditable(&self, can_gc: CanGc) {
if !self.is_editing_host() {
return;
}
let document = self.owner_document();
let Some(selection) = document.GetSelection(can_gc) else {
return;
};
let range = self
.upcast::<Element>()
.ensure_contenteditable_selection_range(&document, can_gc);
// If the current range is already associated with this contenteditable
// element, then we shouldn't do anything. This is important when focus
// is lost and regained, but selection was changed beforehand. In that
// case, we should maintain the selection as it were, by not creating
// a new range.
if selection
.active_range()
.is_some_and(|active| active == range)
{
return;
}
let node = self.upcast::<Node>();
let mut selected_node = DomRoot::from_ref(node);
let mut previous_eligible_node = DomRoot::from_ref(node);
let mut previous_node = DomRoot::from_ref(node);
let mut selected_offset = 0;
for child in node.traverse_preorder(ShadowIncluding::Yes) {
if let Some(text) = child.downcast::<Text>() {
// Note that to consider it whitespace, it needs to take more
// into account than simply "it has a non-whitespace" character.
// Therefore, we need to first check if it is not a whitespace
// node and only then can we find what the relevant character is.
if !text.is_whitespace_node() {
// A node with "white-space: pre" set must select its first
// character, regardless if that's a whitespace character or not.
let is_pre_formatted_text_node = child
.GetParentElement()
.and_then(|parent| parent.style())
.is_some_and(|style| {
style.get_inherited_text().white_space_collapse ==
WhiteSpaceCollapse::Preserve
});
if !is_pre_formatted_text_node {
// If it isn't pre-formatted, then we should instead select the
// first non-whitespace character.
selected_offset = text
.data()
.find(|c: char| !c.is_whitespace())
.unwrap_or_default() as u32;
}
selected_node = child;
break;
}
}
// For <input>, <textarea>, <hr> and <br> elements, we should select the previous
// node, regardless if it was a block node or not
if matches!(
child.type_id(),
NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLInputElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLTextAreaElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLHRElement,
)) | NodeTypeId::Element(ElementTypeId::HTMLElement(
HTMLElementTypeId::HTMLBRElement,
))
) {
selected_node = previous_node;
break;
}
// When we encounter a non-contenteditable element, we should select the previous
// eligible node
if child
.downcast::<HTMLElement>()
.is_some_and(|el| el.ContentEditable().str() == "false")
{
selected_node = previous_eligible_node;
break;
}
// We can only select block nodes as eligible nodes for the case of non-conenteditable
// nodes
if child.is_block_node() {
previous_eligible_node = child.clone();
}
previous_node = child;
}
range.set_start(&selected_node, selected_offset);
range.set_end(&selected_node, selected_offset);
selection.AddRange(&range);
}
}

View File

@@ -0,0 +1,11 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
mod document;
mod element;
pub(crate) mod htmlelement;
pub(crate) mod node;
mod range;
pub(crate) mod selection;
mod text;

View File

@@ -0,0 +1,256 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use js::context::JSContext;
use script_bindings::inheritance::Castable;
use crate::dom::bindings::codegen::Bindings::RangeBinding::RangeMethods;
use crate::dom::bindings::root::DomRoot;
use crate::dom::bindings::str::DOMString;
use crate::dom::document::Document;
use crate::dom::execcommand::basecommand::CommandName;
use crate::dom::execcommand::commands::fontsize::legacy_font_size_for;
use crate::dom::node::{Node, ShadowIncluding};
use crate::dom::range::Range;
use crate::dom::selection::Selection;
use crate::dom::text::Text;
enum BoolOrOptionalString {
Bool(bool),
OptionalString(Option<DOMString>),
}
impl From<Option<DOMString>> for BoolOrOptionalString {
fn from(optional_string: Option<DOMString>) -> Self {
Self::OptionalString(optional_string)
}
}
impl From<bool> for BoolOrOptionalString {
fn from(bool_: bool) -> Self {
Self::Bool(bool_)
}
}
pub(crate) struct RecordedStateOfNode {
command: CommandName,
value: BoolOrOptionalString,
}
impl RecordedStateOfNode {
fn for_command_node(command: CommandName, node: &Node) -> Self {
let value = node.effective_command_value(&command).into();
Self { command, value }
}
}
impl Range {
/// <https://w3c.github.io/editing/docs/execCommand/#effectively-contained>
fn is_effectively_contained_node(&self, node: &Node) -> bool {
// > A node node is effectively contained in a range range if range is not collapsed,
if self.collapsed() {
return false;
}
// > and at least one of the following holds:
// > node is range's start node, it is a Text node, and its length is different from range's start offset.
let start_container = self.start_container();
if *start_container == *node && node.is::<Text>() && node.len() != self.start_offset() {
return true;
}
// > node is range's end node, it is a Text node, and range's end offset is not 0.
let end_container = self.end_container();
if *end_container == *node && node.is::<Text>() && self.end_offset() != 0 {
return true;
}
// > node is contained in range.
if self.contains(node) {
return true;
}
// > node has at least one child; and all its children are effectively contained in range;
node.children_count() > 0 && node.children().all(|child| self.is_effectively_contained_node(&child))
// > and either range's start node is not a descendant of node or is not a Text node or range's start offset is zero;
&& (!node.is_ancestor_of(&start_container) || !start_container.is::<Text>() || self.start_offset() == 0)
// > and either range's end node is not a descendant of node or is not a Text node or range's end offset is its end node's length.
&& (!node.is_ancestor_of(&end_container) || !end_container.is::<Text>() || self.end_offset() == end_container.len())
}
pub(crate) fn first_formattable_contained_node(&self) -> Option<DomRoot<Node>> {
if self.collapsed() {
return None;
}
self.CommonAncestorContainer()
.traverse_preorder(ShadowIncluding::No)
.find(|child| child.is_formattable() && self.is_effectively_contained_node(child))
}
pub(crate) fn for_each_effectively_contained_child<Callback: FnMut(&Node)>(
&self,
mut callback: Callback,
) {
if self.collapsed() {
return;
}
for child in self
.CommonAncestorContainer()
.traverse_preorder(ShadowIncluding::No)
{
if self.is_effectively_contained_node(&child) {
callback(&child);
}
}
}
/// <https://w3c.github.io/editing/docs/execCommand/#record-current-states-and-values>
pub(crate) fn record_current_states_and_values(&self) -> Vec<RecordedStateOfNode> {
// Step 1. Let overrides be a list of (string, string or boolean) ordered pairs, initially empty.
//
// We return the vec in one go for the relevant values
// Step 2. Let node be the first formattable node effectively contained in the active range,
// or null if there is none.
let Some(node) = self.first_formattable_contained_node() else {
// Step 3. If node is null, return overrides.
return vec![];
};
// Step 8. Return overrides.
vec![
// Step 4. Add ("createLink", node's effective command value for "createLink") to overrides.
RecordedStateOfNode::for_command_node(CommandName::CreateLink, &node),
// Step 5. For each command in the list
// "bold", "italic", "strikethrough", "subscript", "superscript", "underline", in order:
// if node's effective command value for command is one of its inline command activated values,
// add (command, true) to overrides, and otherwise add (command, false) to overrides.
// TODO
// Step 6. For each command in the list "fontName", "foreColor", "hiliteColor", in order:
// add (command, command's value) to overrides.
// TODO
// Step 7. Add ("fontSize", node's effective command value for "fontSize") to overrides.
RecordedStateOfNode::for_command_node(CommandName::FontSize, &node),
]
}
/// <https://w3c.github.io/editing/docs/execCommand/#restore-states-and-values>
pub(crate) fn restore_states_and_values(
&self,
cx: &mut JSContext,
selection: &Selection,
context_object: &Document,
overrides: Vec<RecordedStateOfNode>,
) {
// Step 1. Let node be the first formattable node effectively contained in the active range,
// or null if there is none.
let mut first_formattable_contained_node = self.first_formattable_contained_node();
for override_state in overrides {
// Step 2. If node is not null, then for each (command, override) pair in overrides, in order:
if let Some(ref node) = first_formattable_contained_node {
match override_state.value {
// Step 2.1. If override is a boolean, and queryCommandState(command)
// returns something different from override, take the action for command,
// with value equal to the empty string.
BoolOrOptionalString::Bool(bool_)
if override_state
.command
.current_state(context_object)
.is_some_and(|value| value != bool_) =>
{
override_state
.command
.execute(cx, context_object, selection, "".into());
},
BoolOrOptionalString::OptionalString(optional_string) => {
match override_state.command {
// Step 2.3. Otherwise, if override is a string; and command is "createLink";
// and either there is a value override for "createLink" that is not equal to override,
// or there is no value override for "createLink" and node's effective command value
// for "createLink" is not equal to override: take the action for "createLink", with value equal to override.
CommandName::CreateLink => {
let value_override =
context_object.value_override(&CommandName::CreateLink);
if value_override != optional_string {
CommandName::CreateLink.execute(
cx,
context_object,
selection,
optional_string.unwrap_or_default(),
);
}
},
// Step 2.4. Otherwise, if override is a string; and command is "fontSize";
// and either there is a value override for "fontSize" that is not equal to override,
// or there is no value override for "fontSize" and node's effective command value for "fontSize"
// is not loosely equivalent to override:
CommandName::FontSize => {
let value_override =
context_object.value_override(&CommandName::FontSize);
if value_override != optional_string ||
(value_override.is_none() &&
!CommandName::FontSize.are_loosely_equivalent_values(
node.effective_command_value(&CommandName::FontSize)
.as_ref(),
optional_string.as_ref(),
))
{
// Step 2.5. Convert override to an integer number of pixels,
// and set override to the legacy font size for the result.
let pixels = optional_string
.and_then(|value| value.parse::<i32>().ok())
.map(|value| {
legacy_font_size_for(value as f32, context_object)
})
.unwrap_or("7".into());
// Step 2.6. Take the action for "fontSize", with value equal to override.
CommandName::FontSize.execute(
cx,
context_object,
selection,
pixels,
);
}
},
// Step 2.2. Otherwise, if override is a string, and command is neither "createLink" nor "fontSize",
// and queryCommandValue(command) returns something not equivalent to override,
// take the action for command, with value equal to override.
command
if command.current_value(cx, context_object) != optional_string =>
{
command.execute(
cx,
context_object,
selection,
optional_string.unwrap_or_default(),
);
},
// Step 2.5. Otherwise, continue this loop from the beginning.
_ => {
continue;
},
}
},
// Step 2.5. Otherwise, continue this loop from the beginning.
_ => {
continue;
},
}
// Step 2.6. Set node to the first formattable node effectively contained in the active range, if there is one.
first_formattable_contained_node = self.first_formattable_contained_node();
} else {
// Step 3. Otherwise, for each (command, override) pair in overrides, in order:
// Step 3.1. If override is a boolean, set the state override for command to override.
match override_state.value {
BoolOrOptionalString::Bool(bool_) => {
context_object.set_state_override(override_state.command, Some(bool_))
},
// Step 3.2. If override is a string, set the value override for command to override.
BoolOrOptionalString::OptionalString(optional_string) => {
context_object.set_value_override(override_state.command, optional_string)
},
}
}
}
}
}

View File

@@ -0,0 +1,890 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use std::cmp::Ordering;
use js::context::JSContext;
use script_bindings::inheritance::Castable;
use crate::dom::abstractrange::bp_position;
use crate::dom::bindings::codegen::Bindings::CharacterDataBinding::CharacterDataMethods;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::bindings::codegen::Bindings::SelectionBinding::SelectionMethods;
use crate::dom::bindings::codegen::Bindings::TextBinding::TextMethods;
use crate::dom::bindings::root::{DomRoot, DomSlice};
use crate::dom::bindings::str::DOMString;
use crate::dom::characterdata::CharacterData;
use crate::dom::document::Document;
use crate::dom::execcommand::basecommand::CommandName;
use crate::dom::execcommand::contenteditable::node::{
NodeOrString, is_allowed_child, move_preserving_ranges, split_the_parent,
};
use crate::dom::html::htmlbrelement::HTMLBRElement;
use crate::dom::html::htmlelement::HTMLElement;
use crate::dom::html::htmltablecellelement::HTMLTableCellElement;
use crate::dom::html::htmltablerowelement::HTMLTableRowElement;
use crate::dom::html::htmltablesectionelement::HTMLTableSectionElement;
use crate::dom::node::Node;
use crate::dom::selection::Selection;
use crate::dom::text::Text;
use crate::script_runtime::CanGc;
#[derive(Default, PartialEq)]
pub(crate) enum SelectionDeletionBlockMerging {
#[default]
Merge,
Skip,
}
#[derive(Default, PartialEq)]
pub(crate) enum SelectionDeletionStripWrappers {
#[default]
Strip,
}
#[derive(Default, PartialEq)]
pub(crate) enum SelectionDeleteDirection {
#[default]
Forward,
Backward,
}
trait EquivalentPoint {
fn previous_equivalent_point(&self) -> Option<(DomRoot<Node>, u32)>;
fn next_equivalent_point(&self) -> Option<(DomRoot<Node>, u32)>;
fn first_equivalent_point(self) -> (DomRoot<Node>, u32);
fn last_equivalent_point(self) -> (DomRoot<Node>, u32);
}
impl EquivalentPoint for (DomRoot<Node>, u32) {
/// <https://w3c.github.io/editing/docs/execCommand/#previous-equivalent-point>
fn previous_equivalent_point(&self) -> Option<(DomRoot<Node>, u32)> {
let (node, offset) = self;
// Step 1. If node's length is zero, return null.
let len = node.len();
if len == 0 {
return None;
}
// Step 2. If offset is 0, and node's parent is not null, and node is an inline node,
// return (node's parent, node's index).
if *offset == 0 && node.is_inline_node() {
if let Some(parent) = node.GetParentNode() {
return Some((parent, node.index()));
}
}
// Step 3. If node has a child with index offset 1, and that child's length is not zero,
// and that child is an inline node, return (that child, that child's length).
if *offset > 0 {
if let Some(child) = node.children().nth(*offset as usize - 1) {
if !child.is_empty() && child.is_inline_node() {
let len = child.len();
return Some((child, len));
}
}
}
// Step 4. Return null.
None
}
/// <https://w3c.github.io/editing/docs/execCommand/#next-equivalent-point>
fn next_equivalent_point(&self) -> Option<(DomRoot<Node>, u32)> {
let (node, offset) = self;
// Step 1. If node's length is zero, return null.
let len = node.len();
if len == 0 {
return None;
}
// Step 2.
//
// This step does not exist in the spec
// Step 3. If offset is node's length, and node's parent is not null, and node is an inline node,
// return (node's parent, 1 + node's index).
if *offset == len && node.is_inline_node() {
if let Some(parent) = node.GetParentNode() {
return Some((parent, node.index() + 1));
}
}
// Step 4.
//
// This step does not exist in the spec
// Step 5. If node has a child with index offset, and that child's length is not zero,
// and that child is an inline node, return (that child, 0).
if let Some(child) = node.children().nth(*offset as usize) {
if !child.is_empty() && child.is_inline_node() {
return Some((child, 0));
}
}
// Step 6.
//
// This step does not exist in the spec
// Step 7. Return null.
None
}
/// <https://w3c.github.io/editing/docs/execCommand/#first-equivalent-point>
fn first_equivalent_point(self) -> (DomRoot<Node>, u32) {
let mut previous_equivalent_point = self;
// Step 1. While (node, offset)'s previous equivalent point is not null, set (node, offset) to its previous equivalent point.
loop {
if let Some(next) = previous_equivalent_point.previous_equivalent_point() {
previous_equivalent_point = next;
} else {
// Step 2. Return (node, offset).
return previous_equivalent_point;
}
}
}
/// <https://w3c.github.io/editing/docs/execCommand/#last-equivalent-point>
fn last_equivalent_point(self) -> (DomRoot<Node>, u32) {
let mut next_equivalent_point = self;
// Step 1. While (node, offset)'s next equivalent point is not null, set (node, offset) to its next equivalent point.
loop {
if let Some(next) = next_equivalent_point.next_equivalent_point() {
next_equivalent_point = next;
} else {
// Step 2. Return (node, offset).
return next_equivalent_point;
}
}
}
}
impl Selection {
/// <https://w3c.github.io/editing/docs/execCommand/#delete-the-selection>
pub(crate) fn delete_the_selection(
&self,
cx: &mut JSContext,
context_object: &Document,
block_merging: SelectionDeletionBlockMerging,
strip_wrappers: SelectionDeletionStripWrappers,
direction: SelectionDeleteDirection,
) {
// Step 1. If the active range is null, abort these steps and do nothing.
let Some(active_range) = self.active_range() else {
return;
};
// Step 2. Canonicalize whitespace at the active range's start.
active_range
.start_container()
.canonicalize_whitespace(active_range.start_offset(), true);
// Step 3. Canonicalize whitespace at the active range's end.
active_range
.end_container()
.canonicalize_whitespace(active_range.end_offset(), true);
// Step 4. Let (start node, start offset) be the last equivalent point for the active range's start.
let (mut start_node, mut start_offset) =
(active_range.start_container(), active_range.start_offset()).last_equivalent_point();
// Step 5. Let (end node, end offset) be the first equivalent point for the active range's end.
let (mut end_node, mut end_offset) =
(active_range.end_container(), active_range.end_offset()).first_equivalent_point();
// Step 6. If (end node, end offset) is not after (start node, start offset):
if bp_position(&end_node, end_offset, &start_node, start_offset) != Some(Ordering::Greater)
{
// Step 6.1. If direction is "forward", call collapseToStart() on the context object's selection.
if direction == SelectionDeleteDirection::Forward {
if self.CollapseToStart(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to start");
}
} else {
// Step 6.2. Otherwise, call collapseToEnd() on the context object's selection.
if self.CollapseToEnd(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to end");
}
}
// Step 6.3. Abort these steps.
return;
}
// Step 7. If start node is a Text node and start offset is 0, set start offset to the index of start node,
// then set start node to its parent.
if start_node.is::<Text>() && start_offset == 0 {
start_offset = start_node.index();
start_node = start_node
.GetParentNode()
.expect("Must always have a parent");
}
// Step 8. If end node is a Text node and end offset is its length, set end offset to one plus the index of end node,
// then set end node to its parent.
if end_node.is::<Text>() && end_offset == end_node.len() {
end_offset = end_node.index() + 1;
end_node = end_node.GetParentNode().expect("Must always have a parent");
}
// Step 9. Call collapse(start node, start offset) on the context object's selection.
if self
.Collapse(Some(&start_node), start_offset, CanGc::from_cx(cx))
.is_err()
{
unreachable!("Must always be able to collapse");
}
// Step 10. Call extend(end node, end offset) on the context object's selection.
if self
.Extend(&end_node, end_offset, CanGc::from_cx(cx))
.is_err()
{
unreachable!("Must always be able to extend");
}
// Step 11.
//
// This step does not exist in the spec
// Step 12. Let start block be the active range's start node.
let Some(active_range) = self.active_range() else {
return;
};
let mut start_block = active_range.start_container();
// Step 13. While start block's parent is in the same editing host and start block is an inline node,
// set start block to its parent.
loop {
if start_block.is_inline_node() {
if let Some(parent) = start_block.GetParentNode() {
if parent.same_editing_host(&start_node) {
start_block = parent;
continue;
}
}
}
break;
}
// Step 14. If start block is neither a block node nor an editing host,
// or "span" is not an allowed child of start block,
// or start block is a td or th, set start block to null.
let start_block = if (!start_block.is_block_node() && !start_block.is_editing_host()) ||
!is_allowed_child(
NodeOrString::String("span".to_owned()),
NodeOrString::Node(start_block.clone()),
) ||
start_block.is::<HTMLTableCellElement>()
{
None
} else {
Some(start_block)
};
// Step 15. Let end block be the active range's end node.
let mut end_block = active_range.end_container();
// Step 16. While end block's parent is in the same editing host and end block is an inline node, set end block to its parent.
loop {
if end_block.is_inline_node() {
if let Some(parent) = end_block.GetParentNode() {
if parent.same_editing_host(&end_block) {
end_block = parent;
continue;
}
}
}
break;
}
// Step 17. If end block is neither a block node nor an editing host, or "span" is not an allowed child of end block,
// or end block is a td or th, set end block to null.
let end_block = if (!end_block.is_block_node() && !end_block.is_editing_host()) ||
!is_allowed_child(
NodeOrString::String("span".to_owned()),
NodeOrString::Node(end_block.clone()),
) ||
end_block.is::<HTMLTableCellElement>()
{
None
} else {
Some(end_block)
};
// Step 18.
//
// This step does not exist in the spec
// Step 19. Record current states and values, and let overrides be the result.
let overrides = active_range.record_current_states_and_values();
// Step 20.
//
// This step does not exist in the spec
// Step 21. If start node and end node are the same, and start node is an editable Text node:
if start_node == end_node && start_node.is_editable() {
if let Some(start_text) = start_node.downcast::<Text>() {
// Step 21.1. Call deleteData(start offset, end offset start offset) on start node.
if start_text
.upcast::<CharacterData>()
.DeleteData(start_offset, end_offset - start_offset)
.is_err()
{
unreachable!("Must always be able to delete");
}
// Step 21.2. Canonicalize whitespace at (start node, start offset), with fix collapsed space false.
start_node.canonicalize_whitespace(start_offset, false);
// Step 21.3. If direction is "forward", call collapseToStart() on the context object's selection.
if direction == SelectionDeleteDirection::Forward {
if self.CollapseToStart(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to start");
}
} else {
// Step 21.4. Otherwise, call collapseToEnd() on the context object's selection.
if self.CollapseToEnd(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to end");
}
}
// Step 21.5. Restore states and values from overrides.
active_range.restore_states_and_values(cx, self, context_object, overrides);
// Step 21.6. Abort these steps.
return;
}
}
// Step 22. If start node is an editable Text node, call deleteData() on it, with start offset as
// the first argument and (length of start node start offset) as the second argument.
if start_node.is_editable() {
if let Some(start_text) = start_node.downcast::<Text>() {
if start_text
.upcast::<CharacterData>()
.DeleteData(start_offset, start_node.len() - start_offset)
.is_err()
{
unreachable!("Must always be able to delete");
}
}
}
// Step 23. Let node list be a list of nodes, initially empty.
rooted_vec!(let mut node_list);
// Step 24. For each node contained in the active range, append node to node list if the
// last member of node list (if any) is not an ancestor of node; node is editable;
// and node is not a thead, tbody, tfoot, tr, th, or td.
let Ok(contained_children) = active_range.contained_children() else {
unreachable!("Must always have contained children");
};
for node in contained_children.contained_children {
// This type is only used to tell the compiler how to handle the type of `node_list.last()`.
// It is not allowed to add a `& DomRoot<Node>` annotation, as test-tidy disallows that.
// However, if we omit the type, the compiler doesn't know what it is, since we also
// aren't allowed to add a type annotation to `node_list` itself, as that is handled
// by the `rooted_vec` macro. Lastly, we also can't make it `&Node`, since then the compiler
// thinks that the contents of the `RootedVec` is `Node`, whereas it is should be
// `RootedVec<DomRoot<Node>>`. The type alias here doesn't upset test-tidy,
// while also providing the necessary information to the compiler to work.
type DomRootNode = DomRoot<Node>;
if node.is_editable() &&
!(node.is::<HTMLTableSectionElement>() ||
node.is::<HTMLTableRowElement>() ||
node.is::<HTMLTableCellElement>()) &&
node_list
.last()
.is_none_or(|last: &DomRootNode| !last.is_ancestor_of(&node))
{
node_list.push(node);
}
}
// Step 25. For each node in node list:
for node in node_list.iter() {
// Step 25.1. Let parent be the parent of node.
let parent = node.GetParentNode().expect("Must always have a parent");
// Step 25.2. Remove node from parent.
assert!(node.has_parent());
node.remove_self(cx);
// Step 25.3. If the block node of parent has no visible children, and parent is editable or an editing host,
// call createElement("br") on the context object and append the result as the last child of parent.
if parent
.block_node_of()
.is_some_and(|block_node| block_node.children().all(|child| child.is_invisible())) &&
parent.is_editable_or_editing_host()
{
let br = context_object.create_element(cx, "br");
if parent.AppendChild(cx, br.upcast()).is_err() {
unreachable!("Must always be able to append");
}
}
// Step 25.4. If strip wrappers is true or parent is not an inclusive ancestor of start node,
// while parent is an editable inline node with length 0, let grandparent be the parent of parent,
// then remove parent from grandparent, then set parent to grandparent.
if strip_wrappers == SelectionDeletionStripWrappers::Strip ||
!parent.is_inclusive_ancestor_of(&start_node)
{
let mut parent = parent;
loop {
if parent.is_editable() && parent.is_inline_node() && parent.is_empty() {
let grand_parent =
parent.GetParentNode().expect("Must always have a parent");
assert!(parent.has_parent());
parent.remove_self(cx);
parent = grand_parent;
continue;
}
break;
}
}
}
// Step 26. If end node is an editable Text node, call deleteData(0, end offset) on it.
if end_node.is_editable() {
if let Some(end_text) = end_node.downcast::<Text>() {
if end_text
.upcast::<CharacterData>()
.DeleteData(0, end_offset)
.is_err()
{
unreachable!("Must always be able to delete");
}
}
}
// Step 27. Canonicalize whitespace at the active range's start, with fix collapsed space false.
active_range
.start_container()
.canonicalize_whitespace(active_range.start_offset(), false);
// Step 28. Canonicalize whitespace at the active range's end, with fix collapsed space false.
active_range
.end_container()
.canonicalize_whitespace(active_range.end_offset(), false);
// Step 29.
//
// This step does not exist in the spec
// Step 30. If block merging is false, or start block or end block is null, or start block is not
// in the same editing host as end block, or start block and end block are the same:
if block_merging == SelectionDeletionBlockMerging::Skip ||
start_block.as_ref().zip(end_block.as_ref()).is_none_or(
|(start_block, end_block)| {
start_block == end_block || !start_block.same_editing_host(end_block)
},
)
{
// Step 30.1. If direction is "forward", call collapseToStart() on the context object's selection.
if direction == SelectionDeleteDirection::Forward {
if self.CollapseToStart(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to start");
}
} else {
// Step 30.2. Otherwise, call collapseToEnd() on the context object's selection.
if self.CollapseToEnd(CanGc::from_cx(cx)).is_err() {
unreachable!("Should be able to collapse to end");
}
}
// Step 30.3. Restore states and values from overrides.
active_range.restore_states_and_values(cx, self, context_object, overrides);
// Step 30.4. Abort these steps.
return;
}
let start_block = start_block.expect("Already checked for None in previous statement");
let end_block = end_block.expect("Already checked for None in previous statement");
// Step 31. If start block has one child, which is a collapsed block prop, remove its child from it.
if start_block.children_count() == 1 {
let Some(child) = start_block.children().nth(0) else {
unreachable!("Must always have a single child");
};
if child.is_collapsed_block_prop() {
assert!(child.has_parent());
child.remove_self(cx);
}
}
// Step 32. If start block is an ancestor of end block:
if start_block.is_ancestor_of(&end_block) {
// Step 32.1. Let reference node be end block.
let mut reference_node = end_block.clone();
// Step 32.2. While reference node is not a child of start block, set reference node to its parent.
loop {
if start_block.children().all(|child| child != reference_node) {
reference_node = reference_node
.GetParentNode()
.expect("Must always have a parent, at least start_block");
continue;
}
break;
}
// Step 32.3. Call collapse() on the context object's selection,
// with first argument start block and second argument the index of reference node.
if self
.Collapse(
Some(&start_block),
reference_node.index(),
CanGc::from_cx(cx),
)
.is_err()
{
unreachable!("Must always be able to collapse");
}
// Step 32.4. If end block has no children:
if end_block.children_count() == 0 {
let mut end_block = end_block;
// Step 32.4.1. While end block is editable and is the only child of its parent and is not a child of start block,
// let parent equal end block, then remove end block from parent, then set end block to parent.
loop {
if end_block.is_editable() &&
start_block.children().all(|child| child != end_block)
{
if let Some(parent) = end_block.GetParentNode() {
if parent.children_count() == 1 {
assert!(end_block.has_parent());
end_block.remove_self(cx);
end_block = parent;
continue;
}
}
}
break;
}
// Step 32.4.2. If end block is editable and is not an inline node,
// and its previousSibling and nextSibling are both inline nodes,
// call createElement("br") on the context object and insert it into end block's parent immediately after end block.
if end_block.is_editable() &&
!end_block.is_inline_node() &&
end_block
.GetPreviousSibling()
.is_some_and(|previous| previous.is_inline_node())
{
if let Some(next_of_end_block) = end_block.GetNextSibling() {
if next_of_end_block.is_inline_node() {
let br = context_object.create_element(cx, "br");
let parent = end_block
.GetParentNode()
.expect("Must always have a parent");
if parent
.InsertBefore(cx, br.upcast(), Some(&next_of_end_block))
.is_err()
{
unreachable!("Must always be able to insert into parent");
}
}
}
}
// Step 32.4.3. If end block is editable, remove it from its parent.
if end_block.is_editable() {
assert!(end_block.has_parent());
end_block.remove_self(cx);
}
// Step 32.4.4. Restore states and values from overrides.
active_range.restore_states_and_values(cx, self, context_object, overrides);
// Step 32.4.5. Abort these steps.
return;
}
let first_child = end_block
.children()
.nth(0)
.expect("Already checked at least 1 child in previous statement");
// Step 32.5. If end block's firstChild is not an inline node,
// restore states and values from record, then abort these steps.
if !first_child.is_inline_node() {
// TODO: Restore state
return;
}
// Step 32.6. Let children be a list of nodes, initially empty.
rooted_vec!(let mut children);
// Step 32.7. Append the first child of end block to children.
children.push(first_child.as_traced());
// Step 32.8. While children's last member is not a br,
// and children's last member's nextSibling is an inline node,
// append children's last member's nextSibling to children.
loop {
let Some(last) = children.last() else {
break;
};
if last.is::<HTMLBRElement>() {
break;
}
let Some(next) = last.GetNextSibling() else {
break;
};
if next.is_inline_node() {
children.push(next.as_traced());
continue;
}
break;
}
// Step 32.9. Record the values of children, and let values be the result.
// TODO
// Step 32.10. While children's first member's parent is not start block,
// split the parent of children.
loop {
if children
.first()
.and_then(|child| child.GetParentNode())
.is_some_and(|parent_of_child| parent_of_child != start_block)
{
split_the_parent(cx, children.r());
continue;
}
break;
}
// Step 32.11. If children's first member's previousSibling is an editable br,
// remove that br from its parent.
if let Some(first) = children.first() {
if let Some(previous_of_first) = first.GetPreviousSibling() {
if previous_of_first.is_editable() && previous_of_first.is::<HTMLBRElement>() {
assert!(previous_of_first.has_parent());
previous_of_first.remove_self(cx);
}
}
}
// Step 33. Otherwise, if start block is a descendant of end block:
} else if end_block.is_ancestor_of(&start_block) {
// Step 33.1. Call collapse() on the context object's selection,
// with first argument start block and second argument start block's length.
if self
.Collapse(Some(&start_block), start_block.len(), CanGc::from_cx(cx))
.is_err()
{
unreachable!("Must always be able to collapse");
}
// Step 33.2. Let reference node be start block.
let mut reference_node = start_block.clone();
// Step 33.3. While reference node is not a child of end block, set reference node to its parent.
loop {
if end_block.children().all(|child| child != reference_node) {
if let Some(parent) = reference_node.GetParentNode() {
reference_node = parent;
continue;
}
}
break;
}
// Step 33.4. If reference node's nextSibling is an inline node and start block's lastChild is a br,
// remove start block's lastChild from it.
if reference_node
.GetNextSibling()
.is_some_and(|next| next.is_inline_node())
{
if let Some(last) = start_block.children().last() {
if last.is::<HTMLBRElement>() {
assert!(last.has_parent());
last.remove_self(cx);
}
}
}
// Step 33.5. Let nodes to move be a list of nodes, initially empty.
rooted_vec!(let mut nodes_to_move);
// Step 33.6. If reference node's nextSibling is neither null nor a block node,
// append it to nodes to move.
if let Some(next) = reference_node.GetNextSibling() {
if !next.is_block_node() {
nodes_to_move.push(next);
}
}
// Step 33.7. While nodes to move is nonempty and its last member isn't a br
// and its last member's nextSibling is neither null nor a block node,
// append its last member's nextSibling to nodes to move.
loop {
if let Some(last) = nodes_to_move.last() {
if !last.is::<HTMLBRElement>() {
if let Some(next_of_last) = last.GetNextSibling() {
if !next_of_last.is_block_node() {
nodes_to_move.push(next_of_last);
continue;
}
}
}
}
break;
}
// Step 33.8. Record the values of nodes to move, and let values be the result.
// TODO
// Step 33.9. For each node in nodes to move,
// append node as the last child of start block, preserving ranges.
for node in nodes_to_move.iter() {
move_preserving_ranges(cx, node, |cx| start_block.AppendChild(cx, node));
}
// Step 34. Otherwise:
} else {
// Step 34.1. Call collapse() on the context object's selection,
// with first argument start block and second argument start block's length.
if self
.Collapse(Some(&start_block), start_block.len(), CanGc::from_cx(cx))
.is_err()
{
unreachable!("Must always be able to collapse");
}
// Step 34.2. If end block's firstChild is an inline node and start block's lastChild is a br,
// remove start block's lastChild from it.
if end_block
.children()
.nth(0)
.is_some_and(|next| next.is_inline_node())
{
if let Some(last) = start_block.children().last() {
if last.is::<HTMLBRElement>() {
assert!(last.has_parent());
last.remove_self(cx);
}
}
}
// Step 34.3. Record the values of end block's children, and let values be the result.
// TODO
// Step 34.4. While end block has children,
// append the first child of end block to start block, preserving ranges.
loop {
if let Some(first_child) = end_block.children().nth(0) {
move_preserving_ranges(cx, &first_child, |cx| {
start_block.AppendChild(cx, &first_child)
});
continue;
}
break;
}
// Step 34.5. While end block has no children,
// let parent be the parent of end block, then remove end block from parent,
// then set end block to parent.
let mut end_block = end_block;
loop {
if end_block.children_count() == 0 {
if let Some(parent) = end_block.GetParentNode() {
assert!(end_block.has_parent());
end_block.remove_self(cx);
end_block = parent;
continue;
}
}
break;
}
}
// Step 35.
//
// This step does not exist in the spec
// Step 36. Let ancestor be start block.
// TODO
// Step 37. While ancestor has an inclusive ancestor ol in the same editing host whose nextSibling is
// also an ol in the same editing host, or an inclusive ancestor ul in the same editing host whose nextSibling
// is also a ul in the same editing host:
// TODO
// Step 38. Restore the values from values.
// TODO
// Step 39. If start block has no children, call createElement("br") on the context object and
// append the result as the last child of start block.
if start_block.children_count() == 0 {
let br = context_object.create_element(cx, "br");
if start_block.AppendChild(cx, br.upcast()).is_err() {
unreachable!("Must always be able to append");
}
}
// Step 40. Remove extraneous line breaks at the end of start block.
start_block.remove_extraneous_line_breaks_at_the_end_of(cx);
// Step 41. Restore states and values from overrides.
active_range.restore_states_and_values(cx, self, context_object, overrides);
}
/// <https://w3c.github.io/editing/docs/execCommand/#set-the-selection%27s-value>
pub(crate) fn set_the_selection_value(
&self,
cx: &mut JSContext,
new_value: Option<DOMString>,
command: CommandName,
context_object: &Document,
) {
let active_range = self
.active_range()
.expect("Must always have an active range");
// Step 1. Let command be the current command.
//
// Passed as argument
// Step 2. If there is no formattable node effectively contained in the active range:
if active_range.first_formattable_contained_node().is_none() {
// Step 2.1. If command has inline command activated values, set the state override to true if new value is among them and false if it's not.
// TODO
// Step 2.2. If command is "subscript", unset the state override for "superscript".
if command == CommandName::Subscript {
context_object.set_state_override(CommandName::Superscript, None);
}
// Step 2.3. If command is "superscript", unset the state override for "subscript".
if command == CommandName::Superscript {
context_object.set_state_override(CommandName::Subscript, None);
}
// Step 2.4. If new value is null, unset the value override (if any).
// Step 2.5. Otherwise, if command is "createLink" or it has a value specified, set the value override to new value.
context_object.set_value_override(command, new_value);
// Step 2.6. Abort these steps.
return;
}
// Step 3. If the active range's start node is an editable Text node,
// and its start offset is neither zero nor its start node's length,
// call splitText() on the active range's start node,
// with argument equal to the active range's start offset.
// Then set the active range's start node to the result, and its start offset to zero.
let start_node = active_range.start_container();
let start_offset = active_range.start_offset();
if start_node.is_editable() && start_offset != 0 && start_offset != start_node.len() {
if let Some(start_text) = start_node.downcast::<Text>() {
let Ok(start_text) = start_text.SplitText(cx, start_offset) else {
unreachable!("Must always be able to split");
};
active_range.set_start(start_text.upcast(), 0);
}
}
// Step 4. If the active range's end node is an editable Text node,
// and its end offset is neither zero nor its end node's length,
// call splitText() on the active range's end node,
// with argument equal to the active range's end offset.
let end_node = active_range.end_container();
let end_offset = active_range.end_offset();
if end_node.is_editable() && end_offset != 0 && end_offset != end_node.len() {
if let Some(end_text) = end_node.downcast::<Text>() {
if end_text.SplitText(cx, end_offset).is_err() {
unreachable!("Must always be able to split");
};
}
}
// Step 5. Let element list be all editable Elements effectively contained in the active range.
// Step 6. For each element in element list, clear the value of element.
active_range.for_each_effectively_contained_child(|child| {
if child.is_editable() {
if let Some(element_child) = child.downcast::<HTMLElement>() {
element_child.clear_the_value(cx, &command);
}
}
});
// Step 7. Let node list be all editable nodes effectively contained in the active range.
// Step 8. For each node in node list:
active_range.for_each_effectively_contained_child(|child| {
if child.is_editable() {
// Step 8.1. Push down values on node.
child.push_down_values(cx, &command, new_value.clone());
// Step 8.2. If node is an allowed child of "span", force the value of node.
if is_allowed_child(
NodeOrString::Node(DomRoot::from_ref(child)),
NodeOrString::String("span".to_owned()),
) {
child.force_the_value(cx, &command, new_value.as_ref());
}
}
});
}
}

View File

@@ -0,0 +1,157 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use script_bindings::inheritance::Castable;
use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
use crate::dom::bindings::cell::Ref;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::characterdata::CharacterData;
use crate::dom::element::Element;
use crate::dom::html::htmlbrelement::HTMLBRElement;
use crate::dom::html::htmlimageelement::HTMLImageElement;
use crate::dom::node::Node;
use crate::dom::text::Text;
impl Text {
/// <https://dom.spec.whatwg.org/#concept-cd-data>
pub(crate) fn data(&self) -> Ref<'_, String> {
self.upcast::<CharacterData>().data()
}
/// <https://w3c.github.io/editing/docs/execCommand/#whitespace-node>
pub(crate) fn is_whitespace_node(&self) -> bool {
// > A whitespace node is either a Text node whose data is the empty string;
let data = self.data();
if data.is_empty() {
return true;
}
// > 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";
let Some(parent) = self.upcast::<Node>().GetParentElement() else {
return false;
};
// TODO: Optimize the below to only do a traversal once and in the match handle the expected collapse value
let Some(style) = parent.style() else {
return false;
};
let white_space_collapse = style.get_inherited_text().white_space_collapse;
if data
.bytes()
.all(|byte| matches!(byte, b'\t' | b'\n' | b'\r' | b' ')) &&
// Note that for "normal" and "nowrap", the longhand "white-space-collapse: collapse" applies
// https://www.w3.org/TR/css-text-4/#white-space-property
white_space_collapse == WhiteSpaceCollapse::Collapse
{
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".
data.bytes()
.all(|byte| matches!(byte, b'\t' | b'\r' | b' ')) &&
// Note that for "pre-line", the longhand "white-space-collapse: preserve-breaks" applies
// https://www.w3.org/TR/css-text-4/#white-space-property
white_space_collapse == WhiteSpaceCollapse::PreserveBreaks
}
/// <https://w3c.github.io/editing/docs/execCommand/#collapsed-whitespace-node>
pub(crate) fn is_collapsed_whitespace_node(&self) -> bool {
// Step 1. If node is not a whitespace node, return false.
if !self.is_whitespace_node() {
return false;
}
// Step 2. If node's data is the empty string, return true.
if self.data().is_empty() {
return true;
}
// Step 3. Let ancestor be node's parent.
let node = self.upcast::<Node>();
let Some(ancestor) = node.GetParentNode() else {
// Step 4. If ancestor is null, return true.
return true;
};
let mut resolved_ancestor = ancestor.clone();
for parent in ancestor.ancestors() {
// Step 5. If the "display" property of some ancestor of node has resolved value "none", return true.
if parent
.downcast::<Element>()
.is_some_and(Element::is_display_none)
{
return true;
}
// Step 6. While ancestor is not a block node and its parent is not null, set ancestor to its parent.
//
// Note that the spec is written as "while not". Since this is the end-condition, we need to invert
// the condition to decide when to stop.
if parent.is_block_node() {
break;
}
resolved_ancestor = parent;
}
// Step 7. Let reference be node.
// Step 8. While reference is a descendant of ancestor:
// Step 8.1. Let reference be the node before it in tree order.
for reference in node.preceding_nodes(&resolved_ancestor) {
// Step 8.2. If reference is a block node or a br, return true.
if reference.is_block_node() || reference.is::<HTMLBRElement>() {
return true;
}
// Step 8.3. If reference is a Text node that is not a whitespace node, or is an img, break from this loop.
if reference
.downcast::<Text>()
.is_some_and(|text| !text.is_whitespace_node()) ||
reference.is::<HTMLImageElement>()
{
break;
}
}
// Step 9. Let reference be node.
// Step 10. While reference is a descendant of ancestor:
// Step 10.1. Let reference be the node after it in tree order, or null if there is no such node.
for reference in node.following_nodes(&resolved_ancestor) {
// Step 10.2. If reference is a block node or a br, return true.
if reference.is_block_node() || reference.is::<HTMLBRElement>() {
return true;
}
// Step 10.3. If reference is a Text node that is not a whitespace node, or is an img, break from this loop.
if reference
.downcast::<Text>()
.is_some_and(|text| !text.is_whitespace_node()) ||
reference.is::<HTMLImageElement>()
{
break;
}
}
// Step 11. Return false.
false
}
/// Part of <https://w3c.github.io/editing/docs/execCommand/#canonicalize-whitespace>
/// and deduplicated here, since we need to do this for both start and end nodes
pub(crate) fn has_whitespace_and_has_parent_with_whitespace_preserve(
&self,
offset: u32,
space_characters: &'static [&'static char],
) -> bool {
// if 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)
let has_preserve_space = self
.upcast::<Node>()
.GetParentNode()
.and_then(|parent_node| parent_node.downcast::<Element>().and_then(Element::style))
.is_some_and(|style| {
// Note that for "pre" and "pre-wrap", the longhand "white-space-collapse: preserve" applies
// https://www.w3.org/TR/css-text-4/#white-space-property
style.get_inherited_text().white_space_collapse != WhiteSpaceCollapse::Preserve
});
let has_space_character = self
.data()
.chars()
.nth(offset as usize)
.is_some_and(|c| space_characters.contains(&&c));
has_preserve_space && has_space_character
}
}