Skip to main content

script/dom/document/
editing.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 std::cell::RefCell;
6use std::rc::Rc;
7
8use embedder_traits::{EditingActionEvent, EmbedderMsg, InputEventResult};
9use js::context::{JSContext, NoGC};
10use keyboard_types::{Key, Modifiers, NamedKey};
11use script_bindings::codegen::GenericBindings::DocumentBinding::DocumentMethods;
12use script_bindings::codegen::GenericBindings::EventBinding::EventMethods;
13use script_bindings::codegen::GenericBindings::SelectionBinding::SelectionMethods;
14use script_bindings::dom::UnrootedDom;
15use script_bindings::inheritance::Castable;
16use script_bindings::root::DomRoot;
17use script_bindings::str::DOMString;
18use servo_base::generic_channel::GenericCallback;
19
20use crate::dom::clipboardevent::ClipboardEventType;
21use crate::dom::event::{EventBubbles, EventCancelable};
22use crate::dom::execcommand::execcommands::DocumentExecCommandSupport;
23use crate::dom::text_control::TextControlElement;
24use crate::dom::text_input::{InputEventType, IsComposing};
25use crate::dom::types::{
26    ClipboardEvent, DataTransfer, Event, EventTarget, HTMLInputElement, HTMLTextAreaElement,
27    KeyboardEvent,
28};
29use crate::dom::{Document, Node};
30use crate::drag::drag_data_store::{DragDataStore, Kind, Mode};
31
32impl Document {
33    pub(crate) fn editing_context(&self, no_gc: &NoGC, node: &Node) -> EditingContext {
34        if let Ok(editing_context) = EditingContext::try_from(node) {
35            return editing_context;
36        }
37
38        let mut current_node = UnrootedDom::from_ref(node, no_gc);
39        while let Some(parent) = current_node.parent_in_flat_tree(no_gc).into_parent() {
40            if let Ok(editing_context) = EditingContext::try_from(&**parent) {
41                return editing_context;
42            }
43            current_node = parent;
44        }
45
46        EditingContext::Document(DomRoot::from_ref(self))
47    }
48
49    /// <https://www.w3.org/TR/clipboard-apis/#clipboard-actions>
50    pub(crate) fn handle_editing_action(
51        &self,
52        cx: &mut JSContext,
53        node: &Node,
54        action: EditingActionEvent,
55    ) -> InputEventResult {
56        let clipboard_event_type = match action {
57            EditingActionEvent::Copy => ClipboardEventType::Copy,
58            EditingActionEvent::Cut => ClipboardEventType::Cut,
59            EditingActionEvent::Paste => ClipboardEventType::Paste,
60        };
61
62        // The script_triggered flag is set if the action runs because of a script, e.g. document.execCommand()
63        let script_triggered = false;
64
65        // The script_may_access_clipboard flag is set
66        // if action is paste and the script thread is allowed to read from clipboard or
67        // if action is copy or cut and the script thread is allowed to modify the clipboard
68        let script_may_access_clipboard = false;
69
70        // Step 1 If the script-triggered flag is set and the script-may-access-clipboard flag is unset
71        if script_triggered && !script_may_access_clipboard {
72            return InputEventResult::empty();
73        }
74
75        // Step 2 Fire a clipboard event
76        let editing_context = self.editing_context(cx.no_gc(), node);
77        let event_target = editing_context.event_target();
78        let clipboard_event = self.fire_clipboard_event(cx, &event_target, clipboard_event_type);
79
80        let event = clipboard_event.upcast::<Event>();
81        if !event.DefaultPrevented() {
82            // Step 3. If the event was not canceled, then
83            match clipboard_event.clipboard_event_type() {
84                ClipboardEventType::Copy => {
85                    // Step 3.1. Copy the selected contents, if any, to the clipboard.
86                    // Implementations should create alternate text/html and text/plain
87                    // clipboard formats when content in a web page is selected.
88                    if let Some(selection) = editing_context.selection_content(cx) {
89                        self.send_to_embedder(EmbedderMsg::SetClipboardText(
90                            self.webview_id(),
91                            selection,
92                        ));
93                    }
94                    // Step 3.2. Fire a clipboard event named clipboardchange
95                    self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
96
97                    // This is how `true` is returned from this function.
98                    event.mark_as_handled();
99                },
100                ClipboardEventType::Cut => {
101                    if let Some(selection) = editing_context.selection_content(cx) &&
102                        editing_context.cutting_and_pasting_enabled()
103                    {
104                        // Step 3.1. If there is a selection in an editable context where
105                        // cutting is enabled, then
106                        // Step 3.1.1. Copy the selected contents, if any, to the clipboard.
107                        // Implementations should create alternate text/html and text/plain
108                        // clipboard formats when content in a web page is selected.
109                        self.send_to_embedder(EmbedderMsg::SetClipboardText(
110                            self.webview_id(),
111                            selection,
112                        ));
113
114                        // Step 3.1.2. Remove the contents of the selection from the document
115                        // and collapse the selection.
116                        editing_context.remove_the_contents_of_the_selection(cx);
117
118                        // Step 3.1.3. Fire a clipboard event named clipboardchange
119                        self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
120
121                        // Step 3.1.4. Queue tasks to fire any events that should fire due to
122                        // the modification, see §5.3 Integration with other scripts and
123                        // events for details.
124                        editing_context.fire_cut_events();
125
126                        // This is how `true` is returned from this function.
127                        event.mark_as_handled();
128                    }
129                },
130                ClipboardEventType::Paste => {
131                    if editing_context.has_selection_or_cursor() &&
132                        editing_context.cutting_and_pasting_enabled() &&
133                        let Some(text_content) = clipboard_event.text_content()
134                    {
135                        // Step 3.1. If there is a selection or cursor in an editable context
136                        // where pasting is enabled, then
137                        // Step 3.1.1. Insert the most suitable content found on the
138                        // clipboard, if any, into the context.
139                        editing_context.insert_content(cx, &text_content);
140
141                        // Step 3.1.2. Queue tasks to fire any events that should fire due to
142                        // the modification, see §5.3 Integration with other scripts and
143                        // events for details.
144                        editing_context.fire_paste_events(&text_content);
145
146                        // This is how `true` is returned from this function.
147                        event.mark_as_handled();
148                    }
149                },
150                _ => (),
151            }
152        } else {
153            // Step 4 If the event was canceled, then
154            match clipboard_event.clipboard_event_type() {
155                ClipboardEventType::Copy => {
156                    // Step 4.1 Call the write content to the clipboard algorithm,
157                    // passing on the DataTransferItemList items, a clear-was-called flag and a types-to-clear list.
158                    if let Some(clipboard_data) = clipboard_event.clipboard_data() {
159                        let drag_data_store =
160                            clipboard_data.data_store().expect("This shouldn't fail");
161                        self.write_content_to_the_clipboard(&drag_data_store);
162                    }
163                },
164                ClipboardEventType::Cut => {
165                    // Step 4.1 Call the write content to the clipboard algorithm,
166                    // passing on the DataTransferItemList items, a clear-was-called flag and a types-to-clear list.
167                    if let Some(clipboard_data) = clipboard_event.clipboard_data() {
168                        let drag_data_store =
169                            clipboard_data.data_store().expect("This shouldn't fail");
170                        self.write_content_to_the_clipboard(&drag_data_store);
171                    }
172
173                    // Step 4.2 Fire a clipboard event named clipboardchange
174                    self.fire_clipboard_event(cx, &event_target, ClipboardEventType::Change);
175                },
176                // Step 4.1 Return false.
177                ClipboardEventType::Paste => (),
178                _ => (),
179            }
180        }
181
182        // Step 5: Return true from the action.
183        // In this case we are returning the `InputEventResult` instead of true or false.
184        event.flags().into()
185    }
186
187    /// <https://www.w3.org/TR/clipboard-apis/#fire-a-clipboard-event>
188    fn fire_clipboard_event(
189        &self,
190        cx: &mut JSContext,
191        target: &EventTarget,
192        clipboard_event_type: ClipboardEventType,
193    ) -> DomRoot<ClipboardEvent> {
194        let clipboard_event = ClipboardEvent::new(
195            cx,
196            self.window(),
197            None,
198            clipboard_event_type,
199            EventBubbles::Bubbles,
200            EventCancelable::Cancelable,
201            None,
202        );
203
204        // Step 1 Let clear_was_called be false
205        // Step 2 Let types_to_clear an empty list
206        let mut drag_data_store = DragDataStore::new();
207
208        // Step 4 let clipboard-entry be the sequence number of clipboard content, null if the OS doesn't support it.
209
210        // Step 5 let trusted be true if the event is generated by the user agent, false otherwise
211        let trusted = true;
212
213        // Step 6 if the context is editable:
214        // Step 6.2 else TODO require Selection see https://github.com/w3c/clipboard-apis/issues/70
215        // Step 7
216        match clipboard_event.clipboard_event_type() {
217            ClipboardEventType::Copy | ClipboardEventType::Cut => {
218                // Step 7.2.1
219                drag_data_store.set_mode(Mode::ReadWrite);
220            },
221            ClipboardEventType::Paste => {
222                let (callback, receiver) =
223                    GenericCallback::new_blocking().expect("Could not create callback");
224                self.send_to_embedder(EmbedderMsg::GetClipboardText(self.webview_id(), callback));
225                let text_contents = receiver
226                    .recv()
227                    .map(Result::unwrap_or_default)
228                    .unwrap_or_default();
229
230                // Step 7.1.1
231                drag_data_store.set_mode(Mode::ReadOnly);
232                // Step 7.1.2 If trusted or the implementation gives script-generated events access to the clipboard
233                if trusted {
234                    // Step 7.1.2.1 For each clipboard-part on the OS clipboard:
235
236                    // Step 7.1.2.1.1 If clipboard-part contains plain text, then
237                    let data = DOMString::from(text_contents);
238                    let type_ = DOMString::from_static("text/plain");
239                    let _ = drag_data_store.add(Kind::Text { data, type_ });
240
241                    // Step 7.1.2.1.2 TODO If clipboard-part represents file references, then for each file reference
242                    // Step 7.1.2.1.3 TODO If clipboard-part contains HTML- or XHTML-formatted text then
243
244                    // Step 7.1.3 Update clipboard-event-data’s files to match clipboard-event-data’s items
245                    // Step 7.1.4 Update clipboard-event-data’s types to match clipboard-event-data’s items
246                }
247            },
248            ClipboardEventType::Change | ClipboardEventType::Other(..) => (),
249        }
250
251        // Step 3
252        let clipboard_event_data = DataTransfer::new(
253            cx,
254            self.window(),
255            Rc::new(RefCell::new(Some(drag_data_store))),
256        );
257
258        // Step 8
259        clipboard_event.set_clipboard_data(Some(&clipboard_event_data));
260
261        // Step 9
262        let event = clipboard_event.upcast::<Event>();
263        event.set_trusted(trusted);
264
265        // Step 10 Set event’s composed to true.
266        event.set_composed(true);
267
268        // Step 11
269        event.dispatch(cx, target, false);
270
271        DomRoot::from(clipboard_event)
272    }
273
274    /// <https://www.w3.org/TR/clipboard-apis/#write-content-to-the-clipboard>
275    fn write_content_to_the_clipboard(&self, drag_data_store: &DragDataStore) {
276        // Step 1
277        if drag_data_store.list_len() > 0 {
278            // Step 1.1 Clear the clipboard.
279            self.send_to_embedder(EmbedderMsg::ClearClipboard(self.webview_id()));
280            // Step 1.2
281            for item in drag_data_store.iter_item_list() {
282                match item {
283                    Kind::Text { data, .. } => {
284                        // Step 1.2.1.1 Ensure encoding is correct per OS and locale conventions
285                        // Step 1.2.1.2 Normalize line endings according to platform conventions
286                        // Step 1.2.1.3
287                        self.send_to_embedder(EmbedderMsg::SetClipboardText(
288                            self.webview_id(),
289                            data.to_string(),
290                        ));
291                    },
292                    Kind::File { .. } => {
293                        // Step 1.2.2 If data is of a type listed in the mandatory data types list, then
294                        // Step 1.2.2.1 Place part on clipboard with the appropriate OS clipboard format description
295                        // Step 1.2.3 Else this is left to the implementation
296                    },
297                }
298            }
299        } else {
300            // Step 2.1
301            if drag_data_store.clear_was_called {
302                // Step 2.1.1 If types-to-clear list is empty, clear the clipboard
303                self.send_to_embedder(EmbedderMsg::ClearClipboard(self.webview_id()));
304                // Step 2.1.2 Else remove the types in the list from the clipboard
305                // As of now this can't be done with Arboard, and it's possible that will be removed from the spec
306            }
307        }
308    }
309
310    /// <https://w3c.github.io/editing/docs/execCommand/#additional-requirements>
311    pub(crate) fn maybe_perform_editing_command(
312        &self,
313        cx: &mut JSContext,
314        event: &KeyboardEvent,
315    ) -> bool {
316        if !servo_config::pref!(dom_exec_command_enabled) {
317            return false;
318        }
319        // This function does not do any checks for whether or not we are actually inside an
320        // editing host, since those checks are performed by exec_command_for_command_id either way.
321        match event.key() {
322            Key::Named(NamedKey::Enter) => {
323                // TODO: Figure out if the bit about Option+Enter works and whether or not this
324                //       ends up providing the correct behavior on Mac. (i.e. whether or not
325                //       Shift should be accepted in addition to Option.)
326                if event.modifiers().contains(Modifiers::SHIFT) {
327                    // > When the user instructs the user agent to insert a line break inside an
328                    // > editing host without breaking out of the current block, such as by
329                    // > pressing Shift-Enter or Option-Enter while the cursor is in an editable
330                    // > node, the user agent must call execCommand("insertlinebreak") on the
331                    // > relevant document.
332                    self.exec_command_for_command_id(
333                        cx,
334                        DOMString::from_static("insertlinebreak"),
335                        DOMString::new(),
336                    )
337                } else {
338                    // > When the user instructs the user agent to insert a line break inside an
339                    // > editing host, such as by pressing the Enter key while the cursor is in an
340                    // > editable node, the user agent must call execCommand("insertparagraph") on
341                    // > the relevant document.
342                    self.exec_command_for_command_id(
343                        cx,
344                        DOMString::from_static("insertparagraph"),
345                        DOMString::new(),
346                    )
347                }
348            },
349            // > When the user instructs the user agent to delete the previous character inside an
350            // > editing host, such as by pressing the Backspace key while the cursor is in an
351            // > editable node, the user agent must call execCommand("delete") on the relevant
352            // > document.
353            // TODO: Gecko, Chromium and WebKit seem to delete up to the next word boundary on
354            //       Ctrl+Backspace and Ctrl+Delete. We probably want that as well.
355            Key::Named(NamedKey::Backspace) => self.exec_command_for_command_id(
356                cx,
357                DOMString::from_static("delete"),
358                DOMString::new(),
359            ),
360            // > When the user instructs the user agent to delete the next character inside an
361            // > editing host, such as by pressing the Delete key while the cursor is in an
362            // > editable node, the user agent must call execCommand("forwarddelete") on the
363            // > relevant document.
364            Key::Named(NamedKey::Delete) => self.exec_command_for_command_id(
365                cx,
366                DOMString::from_static("forwarddelete"),
367                DOMString::new(),
368            ),
369            // > When the user instructs the user agent to insert text inside an editing host, such
370            // > as by typing on the keyboard while the cursor is in an editable node, the user
371            // > agent must call execCommand("inserttext", false, value) on the relevant document,
372            // > with value equal to the text the user provided. If the user inserts multiple
373            // > characters at once or in quick succession, this specification does not define
374            // > whether it is treated as one insertion or several consecutive insertions.
375            Key::Character(string) => self.exec_command_for_command_id(
376                cx,
377                DOMString::from_static("inserttext"),
378                DOMString::from(string),
379            ),
380            _ => false,
381        }
382    }
383}
384
385pub(crate) enum TextControlElementEditingContext {
386    TextArea(DomRoot<HTMLTextAreaElement>),
387    Input(DomRoot<HTMLInputElement>),
388}
389
390impl TextControlElementEditingContext {
391    fn text_control_element(&self) -> &dyn TextControlElement {
392        match self {
393            TextControlElementEditingContext::TextArea(text_area) => &**text_area,
394            TextControlElementEditingContext::Input(input) => &**input,
395        }
396    }
397}
398
399pub(crate) enum EditingContext {
400    TextControl(TextControlElementEditingContext),
401    Document(DomRoot<Document>),
402}
403
404impl TryFrom<&Node> for EditingContext {
405    type Error = ();
406
407    fn try_from(node: &Node) -> Result<Self, Self::Error> {
408        if let Some(text_area) = node.downcast::<HTMLTextAreaElement>() {
409            return Ok(EditingContext::TextControl(
410                TextControlElementEditingContext::TextArea(DomRoot::from_ref(text_area)),
411            ));
412        }
413        if let Some(input) = node.downcast::<HTMLInputElement>() &&
414            input.is_textual_or_password()
415        {
416            return Ok(EditingContext::TextControl(
417                TextControlElementEditingContext::Input(DomRoot::from_ref(input)),
418            ));
419        }
420        Err(())
421    }
422}
423
424impl EditingContext {
425    pub(crate) fn event_target(&self) -> DomRoot<EventTarget> {
426        match self {
427            EditingContext::TextControl(element) => match element {
428                TextControlElementEditingContext::TextArea(text_area) => {
429                    DomRoot::from_ref(text_area.upcast())
430                },
431                TextControlElementEditingContext::Input(input) => DomRoot::from_ref(input.upcast()),
432            },
433            EditingContext::Document(document) => {
434                document.event_handler().target_for_events_following_focus()
435            },
436        }
437    }
438
439    pub(crate) fn selection_content(&self, cx: &mut JSContext) -> Option<String> {
440        match self {
441            EditingContext::TextControl(element) => element
442                .text_control_element()
443                .text_input()
444                .selection_content(),
445            EditingContext::Document(document) => document
446                .selection()
447                .map(|selection| selection.Stringifier(cx).to_string())
448                .filter(|selection| !selection.is_empty()),
449        }
450    }
451
452    pub(crate) fn has_uncollapsed_selection(&self) -> bool {
453        match self {
454            EditingContext::TextControl(element) => {
455                element.text_control_element().has_uncollapsed_selection()
456            },
457            EditingContext::Document(document) => document
458                .selection()
459                .is_some_and(|selection| !selection.collapsed()),
460        }
461    }
462
463    pub(crate) fn has_selectable_text(&self) -> bool {
464        match self {
465            EditingContext::TextControl(element) => {
466                element.text_control_element().has_selectable_text()
467            },
468            EditingContext::Document(..) => true,
469        }
470    }
471
472    pub(crate) fn has_selection_or_cursor(&self) -> bool {
473        match self {
474            EditingContext::TextControl(..) => true,
475            EditingContext::Document(document) => document
476                .selection()
477                .is_some_and(|selection| selection.RangeCount() > 0),
478        }
479    }
480
481    pub(crate) fn cutting_and_pasting_enabled(&self) -> bool {
482        match self {
483            EditingContext::TextControl(element) => {
484                !element.text_control_element().read_only_or_disabled()
485            },
486            EditingContext::Document(..) => {
487                // TODO(mrobinson): Add support for integration with contenteditable.
488                false
489            },
490        }
491    }
492
493    pub(crate) fn remove_the_contents_of_the_selection(&self, cx: &mut JSContext) {
494        match self {
495            EditingContext::TextControl(element) => {
496                element
497                    .text_control_element()
498                    .remove_the_contents_of_the_selection(cx);
499            },
500            EditingContext::Document(..) => {
501                // TODO(mrobinson): Add support for integration with contenteditable.
502            },
503        }
504    }
505
506    pub(crate) fn fire_cut_events(&self) {
507        match self {
508            EditingContext::TextControl(element) => {
509                element.text_control_element().queue_input_event(
510                    None,
511                    IsComposing::NotComposing,
512                    InputEventType::DeleteByCut,
513                );
514            },
515            EditingContext::Document(..) => {},
516        }
517    }
518
519    pub(crate) fn insert_content(&self, cx: &mut JSContext, text_content: &str) {
520        match self {
521            EditingContext::TextControl(element) => {
522                element
523                    .text_control_element()
524                    .insert_content(cx, text_content);
525            },
526            EditingContext::Document(..) => {
527                // TODO(mrobinson): Add support for integration with contenteditable.
528            },
529        }
530    }
531
532    pub(crate) fn fire_paste_events(&self, text_content: &str) {
533        match self {
534            EditingContext::TextControl(element) => {
535                element.text_control_element().queue_input_event(
536                    Some(text_content.to_owned()),
537                    IsComposing::NotComposing,
538                    InputEventType::InsertFromPaste,
539                );
540            },
541            EditingContext::Document(..) => {},
542        }
543    }
544
545    pub(crate) fn select_all(&self, cx: &mut JSContext) {
546        match self {
547            EditingContext::TextControl(element) => element.text_control_element().select_all(),
548            EditingContext::Document(document) => {
549                let Some(selection) = document.GetSelection(cx) else {
550                    return;
551                };
552                if let Some(node) = document
553                    .GetBody()
554                    .map(DomRoot::upcast::<Node>)
555                    .or_else(|| document.GetDocumentElement().map(DomRoot::upcast::<Node>))
556                {
557                    let _ = selection.SelectAllChildren(cx, &node);
558                }
559            },
560        }
561    }
562}