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        // Some commands are only enabled if the editing host is *not* in plaintext-only state.
104        if !command_name.is_enabled_in_plaintext_only_state() &&
105            (start_container_editing_host.is_in_plaintext_only_state() ||
106                end_container_editing_host.is_in_plaintext_only_state())
107        {
108            None
109        } else {
110            Some(selection)
111        }
112    }
113
114    /// <https://w3c.github.io/editing/docs/execCommand/#supported>
115    fn command_if_command_is_supported(&self, command_id: &DOMString) -> Option<CommandName> {
116        // https://w3c.github.io/editing/docs/execCommand/#methods-to-query-and-execute-commands
117        // > All of these methods must treat their command argument ASCII case-insensitively.
118        Some(match &*command_id.str().to_lowercase() {
119            "backcolor" => CommandName::BackColor,
120            "bold" => CommandName::Bold,
121            "createlink" => CommandName::CreateLink,
122            "delete" => CommandName::Delete,
123            "defaultparagraphseparator" => CommandName::DefaultParagraphSeparator,
124            "fontname" => CommandName::FontName,
125            "fontsize" => CommandName::FontSize,
126            "forecolor" => CommandName::ForeColor,
127            "hilitecolor" => CommandName::HiliteColor,
128            "insertparagraph" => CommandName::InsertParagraph,
129            "italic" => CommandName::Italic,
130            "removeformat" => CommandName::RemoveFormat,
131            "strikethrough" => CommandName::Strikethrough,
132            "stylewithcss" => CommandName::StyleWithCss,
133            "subscript" => CommandName::Subscript,
134            "superscript" => CommandName::Superscript,
135            "underline" => CommandName::Underline,
136            "unlink" => CommandName::Unlink,
137            _ => return None,
138        })
139    }
140}
141
142pub(crate) trait DocumentExecCommandSupport {
143    fn is_command_supported(&self, command_id: DOMString) -> bool;
144    fn is_command_indeterminate(&self, cx: &mut JSContext, command_id: DOMString) -> bool;
145    fn command_state_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> bool;
146    fn command_value_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> DOMString;
147    fn check_support_and_enabled(
148        &self,
149        cx: &mut JSContext,
150        command_id: &DOMString,
151    ) -> Option<(CommandName, DomRoot<Selection>)>;
152    fn exec_command_for_command_id(
153        &self,
154        cx: &mut JSContext,
155        command_id: DOMString,
156        value: DOMString,
157    ) -> bool;
158}
159
160impl DocumentExecCommandSupport for Document {
161    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandsupported()>
162    fn is_command_supported(&self, command_id: DOMString) -> bool {
163        self.command_if_command_is_supported(&command_id).is_some()
164    }
165
166    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandindeterm()>
167    fn is_command_indeterminate(&self, cx: &mut JSContext, command_id: DOMString) -> bool {
168        // Step 1. If command is not supported or has no indeterminacy, return false.
169        // Step 2. Return true if command is indeterminate, otherwise false.
170        self.command_if_command_is_supported(&command_id)
171            .is_some_and(|command| command.is_indeterminate(cx, self))
172    }
173
174    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandstate()>
175    fn command_state_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> bool {
176        // Step 1. If command is not supported or has no state, return false.
177        let Some(command) = self.command_if_command_is_supported(&command_id) else {
178            return false;
179        };
180        let Some(state) = command.current_state(cx, self) else {
181            return false;
182        };
183        // Step 2. If the state override for command is set, return it.
184        // Step 3. Return true if command's state is true, otherwise false.
185        self.state_override(&command).unwrap_or(state)
186    }
187
188    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandvalue()>
189    fn command_value_for_command(&self, cx: &mut JSContext, command_id: DOMString) -> DOMString {
190        // Step 1. If command is not supported or has no value, return the empty string.
191        let Some(command) = self.command_if_command_is_supported(&command_id) else {
192            return DOMString::new();
193        };
194        let Some(value) = command.current_value(cx, self) else {
195            return DOMString::new();
196        };
197        // Step 3. If the value override for command is set, return it.
198        self.value_override(&command)
199            .map(|value_override| {
200                // Step 2. If command is "fontSize" and its value override is set,
201                // convert the value override to an integer number of pixels and return the legacy font size for the result.
202                if command == CommandName::FontSize {
203                    maybe_normalize_pixels(&value_override, self).unwrap_or(value_override)
204                } else {
205                    value_override
206                }
207            })
208            // Step 4. Return command's value.
209            .unwrap_or(value)
210    }
211
212    /// <https://w3c.github.io/editing/docs/execCommand/#querycommandenabled()>
213    fn check_support_and_enabled(
214        &self,
215        cx: &mut JSContext,
216        command_id: &DOMString,
217    ) -> Option<(CommandName, DomRoot<Selection>)> {
218        // Step 2. Return true if command is both supported and enabled, false otherwise.
219        let command = self.command_if_command_is_supported(command_id)?;
220        let selection = self.selection_if_command_is_enabled(cx, command)?;
221        Some((command, selection))
222    }
223
224    /// <https://w3c.github.io/editing/docs/execCommand/#execcommand()>
225    fn exec_command_for_command_id(
226        &self,
227        cx: &mut JSContext,
228        command_id: DOMString,
229        value: DOMString,
230    ) -> bool {
231        let window = self.window();
232        // Step 3. If command is not supported or not enabled, return false.
233        let Some((command, mut selection)) = self.check_support_and_enabled(cx, &command_id) else {
234            return false;
235        };
236        // Step 4. If command is not in the Miscellaneous commands section:
237        let affected_editing_host = if !is_command_listed_in_miscellaneous_section(command) {
238            // Step 4.1. Let affected editing host be the editing host that is an inclusive ancestor
239            // of the active range's start node and end node, and is not the ancestor of any editing host
240            // that is an inclusive ancestor of the active range's start node and end node.
241            let Some(affected_editing_host) = selection
242                .active_range()
243                .expect("Must always have an active range")
244                .CommonAncestorContainer()
245                .editing_host_of()
246            else {
247                return false;
248            };
249
250            // Step 4.2. Fire an event named "beforeinput" at affected editing host using InputEvent,
251            // with its bubbles and cancelable attributes initialized to true, and its data attribute initialized to null
252            let event = InputEvent::new(
253                cx,
254                window,
255                None,
256                atom!("beforeinput"),
257                true,
258                true,
259                Some(window),
260                0,
261                None,
262                false,
263                "".into(),
264            );
265            let event = event.upcast::<Event>();
266            // Step 4.3. If the value returned by the previous step is false, return false.
267            if !event.fire(cx, affected_editing_host.upcast()) {
268                return false;
269            }
270
271            // Step 4.4. If command is not enabled, return false.
272            let Some(new_selection) = self.selection_if_command_is_enabled(cx, command) else {
273                return false;
274            };
275            selection = new_selection;
276
277            // Step 4.5. Let affected editing host be the editing host that is an inclusive ancestor
278            // of the active range's start node and end node, and is not the ancestor of any editing host
279            // that is an inclusive ancestor of the active range's start node and end node.
280            selection
281                .active_range()
282                .expect("Must always have an active range")
283                .CommonAncestorContainer()
284                .editing_host_of()
285        } else {
286            None
287        };
288
289        // Step 5. Take the action for command, passing value to the instructions as an argument.
290        let result = command.execute(cx, self, &selection, value);
291        // Step 6. If the previous step returned false, return false.
292        if !result {
293            return false;
294        }
295        // Step 7. If the action modified DOM tree, then fire an event named "input" at affected editing
296        // host using InputEvent, with its isTrusted and bubbles attributes initialized to true,
297        // inputType attribute initialized to the mapped value of command, and its data attribute initialized to null.
298        if let Some(affected_editing_host) = affected_editing_host {
299            let event = InputEvent::new(
300                cx,
301                window,
302                None,
303                atom!("input"),
304                true,
305                false,
306                Some(window),
307                0,
308                None,
309                false,
310                mapped_value_of_command(command),
311            );
312            let event = event.upcast::<Event>();
313            event.set_trusted(true);
314            event.fire(cx, affected_editing_host.upcast());
315        }
316
317        // Step 8. Return true.
318        true
319    }
320}