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