Skip to main content

script/dom/execcommand/
execcommands.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 cssparser::match_ignore_ascii_case;
6use js::context::JSContext;
7use script_bindings::inheritance::Castable;
8
9use crate::dom::bindings::codegen::Bindings::DocumentBinding::DocumentMethods;
10use crate::dom::bindings::codegen::Bindings::HTMLElementBinding::HTMLElementMethods;
11use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
12use crate::dom::bindings::codegen::Bindings::RangeBinding::RangeMethods;
13use crate::dom::bindings::root::DomRoot;
14use crate::dom::bindings::str::DOMString;
15use crate::dom::comment::Comment;
16use crate::dom::document::Document;
17use crate::dom::event::Event;
18use crate::dom::event::inputevent::InputEvent;
19use crate::dom::execcommand::basecommand::CommandName;
20use crate::dom::execcommand::commands::fontsize::maybe_normalize_pixels;
21use crate::dom::html::htmlelement::HTMLElement;
22use crate::dom::node::Node;
23use crate::dom::processinginstruction::ProcessingInstruction;
24use crate::dom::selection::Selection;
25
26/// <https://w3c.github.io/editing/docs/execCommand/#miscellaneous-commands>
27fn is_command_listed_in_miscellaneous_section(command_name: CommandName) -> bool {
28    matches!(
29        command_name,
30        CommandName::DefaultParagraphSeparator |
31            CommandName::Redo |
32            CommandName::SelectAll |
33            CommandName::StyleWithCss |
34            CommandName::Undo |
35            CommandName::Usecss
36    )
37}
38
39fn bump_selection_out_of_invalid_node(cx: &mut JSContext, selection: &Selection) -> Result<(), ()> {
40    // Note: Here we make sure that if the selection range starts or ends inside of an HTML
41    //       comment or PI, we get it out of there before trying to edit things. Trying to
42    //       perform text editing inside of these nodes doesn't make any sense anyways and
43    //       some commands aren't prepared to handle that. Picking the boundary point right
44    //       before the problematic node is vaguely consistent with other browsers.
45    let active_range = selection
46        .active_range(cx)
47        .expect("Must always have an active range");
48    if let start_container = active_range.start_container() &&
49        (start_container.is::<Comment>() || start_container.is::<ProcessingInstruction>())
50    {
51        let Some(parent) = start_container.GetParentNode() else {
52            return Err(());
53        };
54        let _ = active_range.SetStart(&parent, start_container.index());
55    }
56    if let end_container = active_range.end_container() &&
57        (end_container.is::<Comment>() || end_container.is::<ProcessingInstruction>())
58    {
59        let Some(parent) = end_container.GetParentNode() else {
60            return Err(());
61        };
62        let _ = active_range.SetEnd(&parent, end_container.index());
63    }
64    Ok(())
65}
66
67/// <https://w3c.github.io/editing/docs/execCommand/#dfn-map-an-edit-command-to-input-type-value>
68fn mapped_value_of_command(command: CommandName) -> DOMString {
69    match command {
70        CommandName::BackColor => "formatBackColor",
71        CommandName::Bold => "formatBold",
72        CommandName::CreateLink => "insertLink",
73        CommandName::Cut => "deleteByCut",
74        CommandName::Delete => "deleteContentBackward",
75        CommandName::FontName => "formatFontName",
76        CommandName::ForeColor => "formatFontColor",
77        CommandName::ForwardDelete => "deleteContentForward",
78        CommandName::Indent => "formatIndent",
79        CommandName::InsertHorizontalRule => "insertHorizontalRule",
80        CommandName::InsertLineBreak => "insertLineBreak",
81        CommandName::InsertOrderedList => "insertOrderedList",
82        CommandName::InsertParagraph => "insertParagraph",
83        CommandName::InsertText => "insertText",
84        CommandName::InsertUnorderedList => "insertUnorderedList",
85        CommandName::JustifyCenter => "formatJustifyCenter",
86        CommandName::JustifyFull => "formatJustifyFull",
87        CommandName::JustifyLeft => "formatJustifyLeft",
88        CommandName::JustifyRight => "formatJustifyRight",
89        CommandName::Outdent => "formatOutdent",
90        CommandName::Paste => "insertFromPaste",
91        CommandName::Redo => "historyRedo",
92        CommandName::Strikethrough => "formatStrikeThrough",
93        CommandName::Superscript => "formatSuperscript",
94        CommandName::Undo => "historyUndo",
95        _ => "",
96    }
97    .into()
98}
99
100impl Node {
101    fn is_in_plaintext_only_state(&self) -> bool {
102        self.downcast::<HTMLElement>()
103            .is_some_and(|el| el.ContentEditable().str() == "plaintext-only")
104    }
105}
106
107impl Document {
108    /// <https://w3c.github.io/editing/docs/execCommand/#enabled>
109    fn selection_if_command_is_enabled(
110        &self,
111        cx: &mut JSContext,
112        command_name: CommandName,
113    ) -> Option<DomRoot<Selection>> {
114        let selection = self.GetSelection(cx)?;
115        // > Among commands defined in this specification, those listed in Miscellaneous commands are always enabled,
116        // > except for the cut command and the paste command.
117        //
118        // Note: cut and paste are listed in the "clipboard commands" section, not the miscellaneous section
119        if is_command_listed_in_miscellaneous_section(command_name) {
120            return Some(selection);
121        }
122        // > The other commands defined here are enabled if the active range is not null,
123        let range = selection.active_range(cx)?;
124        // > its start node is either editable or an editing host,
125        let start_container_editing_host = range.start_container().editing_host_of()?;
126        // > the editing host of its start node is not an EditContext editing host,
127        // TODO
128        // > its end node is either editable or an editing host,
129        let end_container_editing_host = range.end_container().editing_host_of()?;
130        // > the editing host of its end node is not an EditContext editing host,
131        // TODO
132        // > and there is some editing host that is an inclusive ancestor of both its start node and its end node.
133        // TODO
134
135        if !command_name.is_enabled(cx, &range, &start_container_editing_host) {
136            return None;
137        }
138
139        // Some commands are only enabled if the editing host is *not* in plaintext-only state.
140        if !command_name.is_enabled_in_plaintext_only_state() &&
141            (start_container_editing_host.is_in_plaintext_only_state() ||
142                end_container_editing_host.is_in_plaintext_only_state())
143        {
144            None
145        } else {
146            Some(selection)
147        }
148    }
149
150    /// <https://w3c.github.io/editing/docs/execCommand/#supported>
151    fn command_if_command_is_supported(&self, command_id: &DOMString) -> Option<CommandName> {
152        // https://w3c.github.io/editing/docs/execCommand/#methods-to-query-and-execute-commands
153        // > All of these methods must treat their command argument ASCII case-insensitively.
154        Some(match_ignore_ascii_case! { &command_id.str(),
155            "backcolor" => CommandName::BackColor,
156            "bold" => CommandName::Bold,
157            "createlink" => CommandName::CreateLink,
158            "delete" => CommandName::Delete,
159            "defaultparagraphseparator" => CommandName::DefaultParagraphSeparator,
160            "fontname" => CommandName::FontName,
161            "fontsize" => CommandName::FontSize,
162            "forecolor" => CommandName::ForeColor,
163            "forwarddelete" => CommandName::ForwardDelete,
164            "hilitecolor" => CommandName::HiliteColor,
165            "indent" => CommandName::Indent,
166            "inserthorizontalrule" => CommandName::InsertHorizontalRule,
167            "insertimage" => CommandName::InsertImage,
168            "insertlinebreak" => CommandName::InsertLineBreak,
169            "insertparagraph" => CommandName::InsertParagraph,
170            "inserttext" => CommandName::InsertText,
171            "italic" => CommandName::Italic,
172            "removeformat" => CommandName::RemoveFormat,
173            "strikethrough" => CommandName::Strikethrough,
174            "stylewithcss" => CommandName::StyleWithCss,
175            "subscript" => CommandName::Subscript,
176            "superscript" => CommandName::Superscript,
177            "underline" => CommandName::Underline,
178            "unlink" => CommandName::Unlink,
179            _ => return None,
180        })
181    }
182}
183
184pub(crate) trait DocumentExecCommandSupport {
185    fn is_command_supported(&self, command_id: DOMString) -> bool;
186    fn is_command_indeterminate(&self, cx: &mut JSContext, command_id: DOMString) -> bool;
187    fn command_state_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> bool;
188    fn command_value_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> DOMString;
189    fn check_support_and_enabled(
190        &self,
191        cx: &mut JSContext,
192        command_id: &DOMString,
193    ) -> Option<(CommandName, DomRoot<Selection>)>;
194    fn exec_command_for_command_id(
195        &self,
196        cx: &mut JSContext,
197        command_id: DOMString,
198        value: DOMString,
199    ) -> bool;
200}
201
202impl DocumentExecCommandSupport for Document {
203    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandsupported()>
204    fn is_command_supported(&self, command_id: DOMString) -> bool {
205        self.command_if_command_is_supported(&command_id).is_some()
206    }
207
208    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandindeterm()>
209    fn is_command_indeterminate(&self, cx: &mut JSContext, command_id: DOMString) -> bool {
210        // Step 1. If command is not supported or has no indeterminacy, return false.
211        // Step 2. Return true if command is indeterminate, otherwise false.
212        self.command_if_command_is_supported(&command_id)
213            .is_some_and(|command| command.is_indeterminate(cx, self))
214    }
215
216    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandstate()>
217    fn command_state_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> bool {
218        // Step 1. If command is not supported or has no state, return false.
219        let Some(command) = self.command_if_command_is_supported(&command_id) else {
220            return false;
221        };
222        let Some(state) = command.current_state(cx, self) else {
223            return false;
224        };
225        // Step 2. If the state override for command is set, return it.
226        // Step 3. Return true if command's state is true, otherwise false.
227        self.state_override(&command).unwrap_or(state)
228    }
229
230    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandvalue()>
231    fn command_value_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> DOMString {
232        // Step 1. If command is not supported or has no value, return the empty string.
233        let Some(command) = self.command_if_command_is_supported(&command_id) else {
234            return DOMString::new();
235        };
236        let Some(value) = command.current_value(cx, self) else {
237            return DOMString::new();
238        };
239        // Step 3. If the value override for command is set, return it.
240        self.value_override(&command)
241            .map(|value_override| {
242                // Step 2. If command is "fontSize" and its value override is set,
243                // convert the value override to an integer number of pixels and return the legacy font size for the result.
244                if command == CommandName::FontSize {
245                    maybe_normalize_pixels(&value_override, self).unwrap_or(value_override)
246                } else {
247                    value_override
248                }
249            })
250            // Step 4. Return command's value.
251            .unwrap_or(value)
252    }
253
254    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandenabled()>
255    fn check_support_and_enabled(
256        &self,
257        cx: &mut JSContext,
258        command_id: &DOMString,
259    ) -> Option<(CommandName, DomRoot<Selection>)> {
260        // Step 2. Return true if command is both supported and enabled, false otherwise.
261        let command = self.command_if_command_is_supported(command_id)?;
262        let selection = self.selection_if_command_is_enabled(cx, command)?;
263        Some((command, selection))
264    }
265
266    /// <https://w3c.github.io/editing/docs/execCommand/#execcommand()>
267    fn exec_command_for_command_id(
268        &self,
269        cx: &mut JSContext,
270        command_id: DOMString,
271        value: DOMString,
272    ) -> bool {
273        let window = self.window();
274        // Step 3. If command is not supported or not enabled, return false.
275        let Some((command, mut selection)) = self.check_support_and_enabled(cx, &command_id) else {
276            return false;
277        };
278        // Step 4. If command is not in the Miscellaneous commands section:
279        let affected_editing_host = if !is_command_listed_in_miscellaneous_section(command) {
280            // Step 4.1. Let affected editing host be the editing host that is an inclusive ancestor
281            // of the active range's start node and end node, and is not the ancestor of any editing host
282            // that is an inclusive ancestor of the active range's start node and end node.
283            let Some(affected_editing_host) = selection
284                .active_range(cx)
285                .expect("Must always have an active range")
286                .CommonAncestorContainer()
287                .editing_host_of()
288            else {
289                return false;
290            };
291
292            // Step 4.2. Fire an event named "beforeinput" at affected editing host using InputEvent,
293            // with its bubbles and cancelable attributes initialized to true, and its data attribute initialized to null
294            let event = InputEvent::new(
295                cx,
296                window,
297                None,
298                atom!("beforeinput"),
299                true,
300                true,
301                Some(window),
302                0,
303                None,
304                false,
305                "".into(),
306            );
307            let event = event.upcast::<Event>();
308            // Step 4.3. If the value returned by the previous step is false, return false.
309            if !event.fire(cx, affected_editing_host.upcast()) {
310                return false;
311            }
312
313            // Step 4.4. If command is not enabled, return false.
314            let Some(new_selection) = self.selection_if_command_is_enabled(cx, command) else {
315                return false;
316            };
317            selection = new_selection;
318
319            // Step 4.5. Let affected editing host be the editing host that is an inclusive ancestor
320            // of the active range's start node and end node, and is not the ancestor of any editing host
321            // that is an inclusive ancestor of the active range's start node and end node.
322            selection
323                .active_range(cx)
324                .expect("Must always have an active range")
325                .CommonAncestorContainer()
326                .editing_host_of()
327        } else {
328            None
329        };
330
331        if affected_editing_host.is_some() &&
332            bump_selection_out_of_invalid_node(cx, &selection).is_err()
333        {
334            return false;
335        }
336
337        // Step 5. Take the action for command, passing value to the instructions as an argument.
338        let result = command.execute(cx, self, &selection, value);
339        // Step 6. If the previous step returned false, return false.
340        if !result {
341            return false;
342        }
343        // Step 7. If the action modified DOM tree, then fire an event named "input" at affected editing
344        // host using InputEvent, with its isTrusted and bubbles attributes initialized to true,
345        // inputType attribute initialized to the mapped value of command, and its data attribute initialized to null.
346        if let Some(affected_editing_host) = affected_editing_host {
347            let event = InputEvent::new(
348                cx,
349                window,
350                None,
351                atom!("input"),
352                true,
353                false,
354                Some(window),
355                0,
356                None,
357                false,
358                mapped_value_of_command(command),
359            );
360            let event = event.upcast::<Event>();
361            event.set_trusted(true);
362            event.fire(cx, affected_editing_host.upcast());
363        }
364
365        // Step 8. Return true.
366        true
367    }
368}