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