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