Skip to main content

script/dom/execcommand/contenteditable/
htmlelement.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use html5ever::local_name;
6use js::context::JSContext;
7use script_bindings::codegen::GenericBindings::RangeBinding::RangeMethods;
8use script_bindings::inheritance::Castable;
9use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
10use style::properties::{LonghandId, PropertyDeclarationId, ShorthandId};
11
12use crate::dom::bindings::codegen::Bindings::DocumentBinding::DocumentMethods;
13use crate::dom::bindings::codegen::Bindings::HTMLElementBinding::HTMLElementMethods;
14use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
15use crate::dom::bindings::codegen::Bindings::SelectionBinding::SelectionMethods;
16use crate::dom::bindings::inheritance::{ElementTypeId, HTMLElementTypeId, NodeTypeId};
17use crate::dom::bindings::root::DomRoot;
18use crate::dom::element::Element;
19use crate::dom::execcommand::basecommand::{CommandName, CssPropertyName};
20use crate::dom::execcommand::contenteditable::node::move_preserving_ranges;
21use crate::dom::html::htmlanchorelement::HTMLAnchorElement;
22use crate::dom::html::htmlelement::HTMLElement;
23use crate::dom::html::htmlfontelement::HTMLFontElement;
24use crate::dom::iterators::ShadowIncluding;
25use crate::dom::node::node::{Node, NodeTraits};
26use crate::dom::text::Text;
27
28impl HTMLElement {
29    pub(crate) fn local_name(&self) -> &str {
30        self.upcast::<Element>().local_name()
31    }
32
33    fn remove_value_from_text_decoration(&self, cx: &mut JSContext, value: &str) {
34        let element = self.upcast::<Element>();
35        let mut original_value = String::new();
36        let property;
37
38        // Ensure that style borrow is dropped before writing new value for style
39        {
40            let style_attribute = element.style_attribute().borrow();
41            let Some(declarations) = style_attribute.as_ref() else {
42                return;
43            };
44            let document = element.owner_document();
45            let shared_lock = document.style_shared_author_lock();
46            let read_lock = shared_lock.read();
47            let style = declarations.read_with(&read_lock);
48
49            // First we need to check if text-decoration is set as shorthand.
50            // If that's not the case, we should only remove underline from text-decoration-line
51            if style
52                .shorthand_to_css(ShorthandId::TextDecoration, &mut original_value)
53                .is_ok()
54            {
55                property = CssPropertyName::TextDecoration;
56            } else if let Some((text_decoration, _)) = style.get(PropertyDeclarationId::Longhand(
57                LonghandId::TextDecorationLine,
58            )) {
59                if text_decoration.to_css(&mut original_value).is_ok() {
60                    property = CssPropertyName::TextDecorationLine;
61                } else {
62                    return;
63                }
64            } else {
65                return;
66            }
67        }
68
69        let new_value = original_value
70            .replace(&format!(" {value} "), " ")
71            .replace(&format!(" {value}"), "")
72            .replace(&format!("{value} "), "")
73            .replace(value, "");
74        if new_value.is_empty() {
75            property.remove_from_element(cx, self);
76        } else {
77            property.set_for_element(cx, self, new_value.into());
78        }
79    }
80
81    /// <https://w3c.github.io/editing/docs/execCommand/#clear-the-value>
82    pub(crate) fn clear_the_value(&self, cx: &mut JSContext, command: &CommandName) {
83        // Step 1. Let command be the current command.
84        //
85        // Passed in as argument
86
87        let node = self.upcast::<Node>();
88        let element = self.upcast::<Element>();
89
90        // Step 2. If element is not editable, return the empty list.
91        if !node.is_editable() {
92            return;
93        }
94        // Step 3. If element's specified command value for command is null,
95        // return the empty list.
96        if element.specified_command_value(command).is_none() {
97            return;
98        }
99        // Step 4. If element is a simple modifiable element:
100        let node_parent = node.GetParentNode().expect("Must always have a parent");
101        if element.is_simple_modifiable_element() {
102            // Step 4.1. Let children be the children of element.
103            // Step 4.2. For each child in children, insert child into element's parent immediately before element, preserving ranges.
104            for child in node.children() {
105                move_preserving_ranges(cx, &child, |cx| {
106                    node_parent.InsertBefore(cx, &child, Some(node))
107                });
108            }
109            // Step 4.3. Remove element from its parent.
110            node.remove_self(cx);
111            // Step 4.4. Return children.
112            return;
113        }
114        match command {
115            // Step 5. If command is "strikethrough", and element has a style attribute
116            // that sets "text-decoration" to some value containing "line-through",
117            // delete "line-through" from the value.
118            CommandName::Strikethrough => {
119                self.remove_value_from_text_decoration(cx, "line-through");
120            },
121            // Step 6. If command is "underline", and element has a style attribute that
122            // sets "text-decoration" to some value containing "underline", delete "underline" from the value.
123            CommandName::Underline => {
124                self.remove_value_from_text_decoration(cx, "underline");
125            },
126            _ => {},
127        }
128        // Step 7. If the relevant CSS property for command is not null,
129        // unset that property of element.
130        if let Some(property) = command.relevant_css_property() {
131            property.remove_from_element(cx, self);
132        }
133        // In case we have a completely empty style attribute, we need to completely remove it.
134        // Otherwise, when you call `innerHTML`, it would generate a `style=""`, which is
135        // not what the tests expect. They expect the whole attribute to be removed.
136        if element.has_empty_style_attribute() {
137            element.remove_attribute_by_name(cx, &local_name!("style"));
138        }
139        // Step 8. If element is a font element:
140        if self.is::<HTMLFontElement>() {
141            match command {
142                // Step 8.1. If command is "foreColor", unset element's color attribute, if set.
143                CommandName::ForeColor => {
144                    element.remove_attribute_by_name(cx, &local_name!("color"));
145                },
146                // Step 8.2. If command is "fontName", unset element's face attribute, if set.
147                CommandName::FontName => {
148                    element.remove_attribute_by_name(cx, &local_name!("face"));
149                },
150                // Step 8.3. If command is "fontSize", unset element's size attribute, if set.
151                CommandName::FontSize => {
152                    element.remove_attribute_by_name(cx, &local_name!("size"));
153                },
154                _ => {},
155            }
156        }
157        // Step 9. If element is an a element and command is "createLink" or "unlink",
158        // unset the href property of element.
159        if self.is::<HTMLAnchorElement>() &&
160            matches!(command, CommandName::CreateLink | CommandName::Unlink)
161        {
162            element.remove_attribute_by_name(cx, &local_name!("href"));
163        }
164        // Step 10. If element's specified command value for command is null,
165        // return the empty list.
166        if element.specified_command_value(command).is_none() {
167            return;
168        }
169        // Step 11. Set the tag name of element to "span",
170        // and return the one-node list consisting of the result.
171        element.set_the_tag_name(cx, "span");
172    }
173
174    /// There is no specification for this implementation. Instead, it is
175    /// reverse-engineered based on the WPT test
176    /// /selection/contenteditable/initial-selection-on-focus.tentative.html
177    pub(crate) fn handle_focus_state_for_contenteditable(&self, cx: &mut JSContext) {
178        if !self.is_editing_host() {
179            return;
180        }
181        let document = self.owner_document();
182        let Some(selection) = document.GetSelection(cx) else {
183            return;
184        };
185        let range = self
186            .upcast::<Element>()
187            .ensure_contenteditable_selection_range(cx, &document);
188        // If the current range is already associated with this contenteditable
189        // element, then we shouldn't do anything. This is important when focus
190        // is lost and regained, but selection was changed beforehand. In that
191        // case, we should maintain the selection as it were, by not creating
192        // a new range.
193        if selection
194            .active_range(cx)
195            .is_some_and(|active| active == range)
196        {
197            return;
198        }
199        let node = self.upcast::<Node>();
200        let mut selected_node = DomRoot::from_ref(node);
201        let mut previous_eligible_node = DomRoot::from_ref(node);
202        let mut previous_node = DomRoot::from_ref(node);
203        let mut selected_offset = 0;
204        for child in node.traverse_preorder(ShadowIncluding::Yes) {
205            if let Some(text) = child.downcast::<Text>() {
206                // Note that to consider it whitespace, it needs to take more
207                // into account than simply "it has a non-whitespace" character.
208                // Therefore, we need to first check if it is not a whitespace
209                // node and only then can we find what the relevant character is.
210                if !text.is_whitespace_node() {
211                    // A node with "white-space: pre" set must select its first
212                    // character, regardless if that's a whitespace character or not.
213                    let is_pre_formatted_text_node = child
214                        .GetParentElement()
215                        .and_then(|parent| parent.style())
216                        .is_some_and(|style| {
217                            style.get_inherited_text().white_space_collapse ==
218                                WhiteSpaceCollapse::Preserve
219                        });
220                    if !is_pre_formatted_text_node {
221                        // If it isn't pre-formatted, then we should instead select the
222                        // first non-whitespace character.
223                        selected_offset = text
224                            .data()
225                            .find(|c: char| !c.is_whitespace())
226                            .unwrap_or_default() as u32;
227                    }
228                    selected_node = child;
229                    break;
230                }
231            }
232            // For <input>, <textarea>, <hr> and <br> elements, we should select the previous
233            // node, regardless if it was a block node or not
234            if matches!(
235                child.type_id(),
236                NodeTypeId::Element(ElementTypeId::HTMLElement(
237                    HTMLElementTypeId::HTMLInputElement,
238                )) | NodeTypeId::Element(ElementTypeId::HTMLElement(
239                    HTMLElementTypeId::HTMLTextAreaElement,
240                )) | NodeTypeId::Element(ElementTypeId::HTMLElement(
241                    HTMLElementTypeId::HTMLHRElement,
242                )) | NodeTypeId::Element(ElementTypeId::HTMLElement(
243                    HTMLElementTypeId::HTMLBRElement,
244                ))
245            ) {
246                selected_node = previous_node;
247                break;
248            }
249            // When we encounter a non-contenteditable element, we should select the previous
250            // eligible node
251            if child
252                .downcast::<HTMLElement>()
253                .is_some_and(|el| el.ContentEditable().str() == "false")
254            {
255                selected_node = previous_eligible_node;
256                break;
257            }
258            // We can only select block nodes as eligible nodes for the case of non-conenteditable
259            // nodes
260            if child.is_block_node() {
261                previous_eligible_node = child.clone();
262            }
263            previous_node = child;
264        }
265        let _ = range.SetStart(&selected_node, selected_offset);
266        let _ = range.SetEnd(&selected_node, selected_offset);
267        selection.AddRange(&range);
268    }
269}