diff --git a/components/script/dom/document/focus.rs b/components/script/dom/document/focus.rs index f36ff2132b7..94d837b700d 100644 --- a/components/script/dom/document/focus.rs +++ b/components/script/dom/document/focus.rs @@ -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}; diff --git a/components/script/dom/execcommand/commands/delete.rs b/components/script/dom/execcommand/commands/delete.rs index 752f9aabf22..9846a2a8b7c 100644 --- a/components/script/dom/execcommand/commands/delete.rs +++ b/components/script/dom/execcommand/commands/delete.rs @@ -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; diff --git a/components/script/dom/execcommand/commands/fontsize.rs b/components/script/dom/execcommand/commands/fontsize.rs index 75083af59d6..c9480b7daeb 100644 --- a/components/script/dom/execcommand/commands/fontsize.rs +++ b/components/script/dom/execcommand/commands/fontsize.rs @@ -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; diff --git a/components/script/dom/execcommand/contenteditable/document.rs b/components/script/dom/execcommand/contenteditable/document.rs new file mode 100644 index 00000000000..11f5538ede4 --- /dev/null +++ b/components/script/dom/execcommand/contenteditable/document.rs @@ -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 { + let element_options = + StringOrElementCreationOptions::ElementCreationOptions(ElementCreationOptions { + is: None, + }); + self.CreateElement(cx, name.into(), element_options) + .expect("Must always be able to create element") + } +} diff --git a/components/script/dom/execcommand/contenteditable/element.rs b/components/script/dom/execcommand/contenteditable/element.rs new file mode 100644 index 00000000000..e4c20e52f5f --- /dev/null +++ b/components/script/dom/execcommand/contenteditable/element.rs @@ -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 { + self.style().map(|style| style.get_box().display.outside()) + } + + /// + pub(crate) fn specified_command_value(&self, command: &CommandName) -> Option { + 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::() { + 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, + } + } + + /// + pub(crate) fn is_simple_modifiable_element(&self) -> bool { + let attrs = self.attrs(); + let attr_count = attrs.len(); + let type_id = self.upcast::().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 + } +} diff --git a/components/script/dom/execcommand/contenteditable/htmlelement.rs b/components/script/dom/execcommand/contenteditable/htmlelement.rs new file mode 100644 index 00000000000..6824bfabd7a --- /dev/null +++ b/components/script/dom/execcommand/contenteditable/htmlelement.rs @@ -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::().local_name() + } + + /// + 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::(); + let element = self.upcast::(); + + // 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::() { + 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::() && + 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::() + .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::(); + 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::() { + // 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 ,