Skip to main content

script/dom/document/
document_embedder_controls.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
5#![cfg_attr(crown, allow(crown::jscontext_first_arg))]
6
7use std::cell::Cell;
8
9use embedder_traits::{
10    ClipboardAction, ContextMenuAction, ContextMenuElementInformation,
11    ContextMenuElementInformationFlags, ContextMenuItem, ContextMenuRequest, EmbedderControlId,
12    EmbedderControlRequest, EmbedderControlResponse, EmbedderMsg,
13};
14use euclid::{Point2D, Rect, Size2D};
15use js::context::JSContext;
16use net_traits::CoreResourceMsg;
17use net_traits::filemanager_thread::FileManagerThreadMsg;
18use rustc_hash::FxHashMap;
19use script_bindings::cell::DomRefCell;
20use script_bindings::codegen::GenericBindings::HTMLAnchorElementBinding::HTMLAnchorElementMethods;
21use script_bindings::codegen::GenericBindings::HTMLImageElementBinding::HTMLImageElementMethods;
22use script_bindings::codegen::GenericBindings::HistoryBinding::HistoryMethods;
23use script_bindings::codegen::GenericBindings::WindowBinding::WindowMethods;
24use script_bindings::inheritance::Castable;
25use script_bindings::root::{Dom, DomRoot};
26use servo_base::Epoch;
27use servo_base::generic_channel::GenericSend;
28use servo_url::ServoUrl;
29use webrender_api::units::{DeviceIntRect, DevicePoint};
30
31use crate::dom::activation::Activatable;
32use crate::dom::bindings::trace::NoTrace;
33use crate::dom::inputevent::HitTestResult;
34use crate::dom::iterators::ShadowIncluding;
35use crate::dom::links::htmlhyperlinkelementutils::HyperlinkElementTraits;
36use crate::dom::node::{Node, NodeTraits};
37use crate::dom::types::{
38    Element, HTMLAnchorElement, HTMLElement, HTMLImageElement, HTMLInputElement, HTMLSelectElement,
39    Window,
40};
41use crate::messaging::MainThreadScriptMsg;
42
43#[derive(JSTraceable, MallocSizeOf)]
44#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
45pub(crate) enum ControlElement {
46    Select(Dom<HTMLSelectElement>),
47    ColorInput(Dom<HTMLInputElement>),
48    FileInput(Dom<HTMLInputElement>),
49    Ime(Dom<HTMLElement>),
50    ContextMenu(ContextMenuNodes),
51}
52
53impl ControlElement {
54    fn node(&self) -> &Node {
55        match self {
56            ControlElement::Select(element) => element.upcast::<Node>(),
57            ControlElement::ColorInput(element) => element.upcast::<Node>(),
58            ControlElement::FileInput(element) => element.upcast::<Node>(),
59            ControlElement::Ime(element) => element.upcast::<Node>(),
60            ControlElement::ContextMenu(context_menu_nodes) => &context_menu_nodes.node,
61        }
62    }
63}
64
65impl js::gc::Rootable for ControlElement {}
66
67#[derive(JSTraceable, MallocSizeOf)]
68#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
69pub(crate) struct DocumentEmbedderControls {
70    /// The [`Window`] element for this [`DocumentUserInterfaceElements`].
71    window: Dom<Window>,
72    /// The id of the next user interface element that the `Document` requests that the
73    /// embedder show. This is used to track user interface elements in the API.
74    #[no_trace]
75    user_interface_element_index: Cell<Epoch>,
76    /// A map of visible user interface elements.
77    visible_elements: DomRefCell<FxHashMap<NoTrace<Epoch>, ControlElement>>,
78}
79
80impl DocumentEmbedderControls {
81    pub fn new(window: &Window) -> Self {
82        Self {
83            window: Dom::from_ref(window),
84            user_interface_element_index: Default::default(),
85            visible_elements: Default::default(),
86        }
87    }
88
89    /// Generate the next unused [`EmbedderControlId`]. This method is only needed for some older
90    /// types of controls that are still being migrated, and it will eventually be removed.
91    pub(crate) fn next_control_id(&self) -> EmbedderControlId {
92        let index = self.user_interface_element_index.get();
93        self.user_interface_element_index.set(index.next());
94        EmbedderControlId {
95            webview_id: self.window.webview_id(),
96            pipeline_id: self.window.pipeline_id(),
97            index,
98        }
99    }
100
101    #[cfg_attr(crown, expect(crown::unrooted_must_root))]
102    pub(crate) fn show_embedder_control(
103        &self,
104        cx: &mut JSContext,
105        element: ControlElement,
106        request: EmbedderControlRequest,
107        point: Option<DevicePoint>,
108    ) -> EmbedderControlId {
109        let id = self.next_control_id();
110        let rect = point
111            .map(|point| DeviceIntRect::from_origin_and_size(point.to_i32(), Size2D::zero()))
112            .unwrap_or_else(|| {
113                let rect = element
114                    .node()
115                    .upcast::<Node>()
116                    .border_box(cx)
117                    .unwrap_or_default();
118
119                let rect = Rect::new(
120                    Point2D::new(rect.origin.x.to_px(), rect.origin.y.to_px()),
121                    Size2D::new(rect.size.width.to_px(), rect.size.height.to_px()),
122                );
123
124                // FIXME: This is a CSS pixel rect relative to this frame, we need a DevicePixel rectangle
125                // relative to the entire WebView!
126                DeviceIntRect::from_untyped(&rect.to_box2d())
127            });
128
129        self.visible_elements
130            .borrow_mut()
131            .insert(id.index.into(), element);
132
133        self.send_embedder_control_request(request, id, rect);
134        id
135    }
136
137    fn send_embedder_control_request(
138        &self,
139        request: EmbedderControlRequest,
140        id: EmbedderControlId,
141        rect: DeviceIntRect,
142    ) {
143        match request {
144            EmbedderControlRequest::SelectElement(..) |
145            EmbedderControlRequest::ColorPicker(..) |
146            EmbedderControlRequest::InputMethod(..) |
147            EmbedderControlRequest::ContextMenu(..) => self
148                .window
149                .send_to_embedder(EmbedderMsg::ShowEmbedderControl(id, rect, request)),
150            EmbedderControlRequest::FilePicker(file_picker_request) => {
151                let main_thread_sender = self.window.main_thread_script_chan().clone();
152                let callback =
153                    profile_traits::generic_callback::GenericCallback::new(move |result| {
154                        let Ok(embedder_control_response) = result else {
155                            return;
156                        };
157                        if let Err(error) = main_thread_sender.send(
158                            MainThreadScriptMsg::ForwardEmbedderControlResponseFromFileManager(
159                                id,
160                                embedder_control_response,
161                            ),
162                        ) {
163                            warn!("Could not send FileManager response to main thread: {error}")
164                        }
165                    })
166                    .expect("Could not create callback");
167                self.window
168                    .as_global_scope()
169                    .resource_threads()
170                    .sender()
171                    .send(CoreResourceMsg::ToFileManager(
172                        FileManagerThreadMsg::SelectFiles(id, file_picker_request, callback),
173                    ))
174                    .unwrap();
175            },
176        }
177    }
178
179    pub(crate) fn hide_embedder_control(&self, element: &Element) {
180        self.visible_elements
181            .borrow_mut()
182            .retain(|index, control_element| {
183                if control_element.node() != element.upcast() {
184                    return true;
185                }
186                let id = EmbedderControlId {
187                    webview_id: self.window.webview_id(),
188                    pipeline_id: self.window.pipeline_id(),
189                    index: index.0,
190                };
191                self.window
192                    .send_to_embedder(EmbedderMsg::HideEmbedderControl(id));
193                false
194            });
195    }
196
197    pub(crate) fn handle_embedder_control_response(
198        &self,
199        cx: &mut JSContext,
200        id: EmbedderControlId,
201        response: EmbedderControlResponse,
202    ) {
203        assert_eq!(self.window.pipeline_id(), id.pipeline_id);
204        assert_eq!(self.window.webview_id(), id.webview_id);
205
206        rooted!(&in(cx) let visible_element = self.visible_elements.borrow_mut().remove(&id.index.into()));
207
208        let Some(element) = &*visible_element else {
209            return;
210        };
211
212        // Never process embedder responses on inactive `Document`s.
213        if !element.node().owner_doc().is_active() {
214            return;
215        }
216
217        match (element, response) {
218            (
219                ControlElement::Select(select_element),
220                EmbedderControlResponse::SelectElement(response),
221            ) => {
222                select_element.handle_embedder_response(cx, response);
223            },
224            (
225                ControlElement::ColorInput(input_element),
226                EmbedderControlResponse::ColorPicker(response),
227            ) => {
228                input_element.handle_color_picker_response(cx, response);
229            },
230            (
231                ControlElement::FileInput(input_element),
232                EmbedderControlResponse::FilePicker(response),
233            ) => {
234                input_element.handle_file_picker_response(cx, response);
235            },
236            (
237                ControlElement::ContextMenu(context_menu_nodes),
238                EmbedderControlResponse::ContextMenu(action),
239            ) => {
240                context_menu_nodes.handle_context_menu_action(action, cx);
241            },
242            (_, _) => unreachable!(
243                "The response to a form control should always match it's originating type."
244            ),
245        }
246    }
247
248    pub(crate) fn show_context_menu(&self, cx: &mut JSContext, hit_test_result: &HitTestResult) {
249        {
250            let mut visible_elements = self.visible_elements.borrow_mut();
251            visible_elements.retain(|index, control_element| {
252                if matches!(control_element, ControlElement::ContextMenu(..)) {
253                    let id = EmbedderControlId {
254                        webview_id: self.window.webview_id(),
255                        pipeline_id: self.window.pipeline_id(),
256                        index: index.0,
257                    };
258                    self.window
259                        .send_to_embedder(EmbedderMsg::HideEmbedderControl(id));
260                    false
261                } else {
262                    true
263                }
264            });
265        }
266
267        let mut anchor_element = None;
268        let mut image_element = None;
269        for node in hit_test_result
270            .node
271            .inclusive_ancestors(ShadowIncluding::Yes)
272        {
273            if anchor_element.is_none() &&
274                let Some(candidate_anchor_element) = node.downcast::<HTMLAnchorElement>() &&
275                candidate_anchor_element.is_instance_activatable()
276            {
277                anchor_element = Some(DomRoot::from_ref(candidate_anchor_element));
278            }
279
280            if image_element.is_none() &&
281                let Some(candidate_image_element) = node.downcast::<HTMLImageElement>()
282            {
283                image_element = Some(DomRoot::from_ref(candidate_image_element))
284            }
285        }
286
287        let mut info = ContextMenuElementInformation::default();
288        let mut items = Vec::new();
289        if let Some(anchor_element) = anchor_element.as_ref() {
290            info.flags.insert(ContextMenuElementInformationFlags::Link);
291            info.link_url = anchor_element
292                .full_href_url_for_user_interface()
293                .map(ServoUrl::into_url);
294
295            items.extend(vec![
296                ContextMenuItem::Item {
297                    label: "Open Link in New View".into(),
298                    action: ContextMenuAction::OpenLinkInNewWebView,
299                    enabled: true,
300                },
301                ContextMenuItem::Item {
302                    label: "Copy Link".into(),
303                    action: ContextMenuAction::CopyLink,
304                    enabled: true,
305                },
306                ContextMenuItem::Separator,
307            ]);
308        }
309
310        if let Some(image_element) = image_element.as_ref() {
311            info.flags.insert(ContextMenuElementInformationFlags::Image);
312            info.image_url = image_element
313                .full_image_url_for_user_interface()
314                .map(ServoUrl::into_url);
315
316            items.extend(vec![
317                ContextMenuItem::Item {
318                    label: "Open Image in New View".into(),
319                    action: ContextMenuAction::OpenImageInNewView,
320                    enabled: true,
321                },
322                ContextMenuItem::Item {
323                    label: "Copy Image Link".into(),
324                    action: ContextMenuAction::CopyImageLink,
325                    enabled: true,
326                },
327                ContextMenuItem::Separator,
328            ]);
329        }
330
331        let document = self.window.Document();
332        let editing_context = document.editing_context(cx.no_gc(), &hit_test_result.node);
333
334        let has_selection = editing_context.has_uncollapsed_selection();
335        if has_selection {
336            info.flags
337                .insert(ContextMenuElementInformationFlags::Selection);
338        }
339
340        let can_cut = has_selection && editing_context.cutting_enabled();
341        let can_copy = has_selection && editing_context.copying_enabled();
342        let can_paste = editing_context.pasting_enabled();
343        if can_paste {
344            info.flags
345                .insert(ContextMenuElementInformationFlags::EditableText);
346        }
347        items.extend(vec![
348            ContextMenuItem::Item {
349                label: "Cut".into(),
350                action: ContextMenuAction::Cut,
351                enabled: can_cut,
352            },
353            ContextMenuItem::Item {
354                label: "Copy".into(),
355                action: ContextMenuAction::Copy,
356                enabled: can_copy,
357            },
358            ContextMenuItem::Item {
359                label: "Paste".into(),
360                action: ContextMenuAction::Paste,
361                enabled: can_paste,
362            },
363            ContextMenuItem::Item {
364                label: "Select All".into(),
365                action: ContextMenuAction::SelectAll,
366                enabled: editing_context.has_selectable_text(),
367            },
368            ContextMenuItem::Separator,
369        ]);
370
371        items.extend(vec![
372            ContextMenuItem::Item {
373                label: "Back".into(),
374                action: ContextMenuAction::GoBack,
375                enabled: true,
376            },
377            ContextMenuItem::Item {
378                label: "Forward".into(),
379                action: ContextMenuAction::GoForward,
380                enabled: true,
381            },
382            ContextMenuItem::Item {
383                label: "Reload".into(),
384                action: ContextMenuAction::Reload,
385                enabled: true,
386            },
387        ]);
388
389        self.show_embedder_control(
390            cx,
391            ControlElement::ContextMenu(ContextMenuNodes {
392                node: hit_test_result.node.as_traced(),
393                anchor_element: anchor_element.map(|element| element.as_traced()),
394                image_element: image_element.map(|element| element.as_traced()),
395            }),
396            EmbedderControlRequest::ContextMenu(ContextMenuRequest {
397                element_info: info,
398                items,
399            }),
400            Some(hit_test_result.point_in_frame.cast_unit()),
401        );
402    }
403}
404
405#[derive(JSTraceable, MallocSizeOf)]
406#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
407pub(crate) struct ContextMenuNodes {
408    /// The node that this menu was triggered on.
409    node: Dom<Node>,
410    /// The first inclusive ancestor of this node that is an `<a>` if one exists.
411    anchor_element: Option<Dom<HTMLAnchorElement>>,
412    /// The first inclusive ancestor of this node that is an `<img>` if one exists.
413    image_element: Option<Dom<HTMLImageElement>>,
414}
415
416impl ContextMenuNodes {
417    fn handle_context_menu_action(&self, action: Option<ContextMenuAction>, cx: &mut JSContext) {
418        let Some(action) = action else {
419            return;
420        };
421
422        let window = self.node.owner_window();
423        let document = window.Document();
424        let set_clipboard_text = |string: String| {
425            if string.is_empty() {
426                return;
427            }
428            window.send_to_embedder(EmbedderMsg::SetClipboardText(window.webview_id(), string));
429        };
430
431        match action {
432            ContextMenuAction::GoBack => {
433                let _ = window.History(cx).Back();
434            },
435            ContextMenuAction::GoForward => {
436                let _ = window.History(cx).Forward();
437            },
438            ContextMenuAction::Reload => {
439                window.Location(cx).reload_without_origin_check(cx, &window);
440            },
441            ContextMenuAction::CopyLink => {
442                let Some(anchor_element) = &self.anchor_element else {
443                    return;
444                };
445
446                let url_string = anchor_element
447                    .full_href_url_for_user_interface()
448                    .as_ref()
449                    .map(ServoUrl::to_string)
450                    .unwrap_or_else(|| String::from(anchor_element.Href(cx.no_gc())));
451                set_clipboard_text(url_string);
452            },
453            ContextMenuAction::OpenLinkInNewWebView => {
454                let Some(anchor_element) = &self.anchor_element else {
455                    return;
456                };
457                if let Some(url) = anchor_element.full_href_url_for_user_interface() {
458                    document.event_handler().open_url_in_new_webview(cx, url);
459                };
460            },
461            ContextMenuAction::CopyImageLink => {
462                let Some(image_element) = &self.image_element else {
463                    return;
464                };
465                let url_string = image_element
466                    .full_image_url_for_user_interface()
467                    .as_ref()
468                    .map(ServoUrl::to_string)
469                    .unwrap_or_else(|| String::from(image_element.CurrentSrc()));
470                set_clipboard_text(url_string);
471            },
472            ContextMenuAction::OpenImageInNewView => {
473                let Some(image_element) = &self.image_element else {
474                    return;
475                };
476                if let Some(url) = image_element.full_image_url_for_user_interface() {
477                    document.event_handler().open_url_in_new_webview(cx, url);
478                }
479            },
480            ContextMenuAction::Cut => {
481                let editing_context = document.editing_context(cx.no_gc(), &self.node);
482                document.handle_clipboard_action(cx, &editing_context, ClipboardAction::Cut);
483            },
484            ContextMenuAction::Copy => {
485                let editing_context = document.editing_context(cx.no_gc(), &self.node);
486                document.handle_clipboard_action(cx, &editing_context, ClipboardAction::Copy);
487            },
488            ContextMenuAction::Paste => {
489                let editing_context = document.editing_context(cx.no_gc(), &self.node);
490                document.handle_clipboard_action(cx, &editing_context, ClipboardAction::Paste);
491            },
492            ContextMenuAction::SelectAll => {
493                let editing_context = document.editing_context(cx.no_gc(), &self.node);
494                editing_context.select_all(cx);
495            },
496        }
497    }
498}