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