Skip to main content

perspective_viewer/components/
panel_menu.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! The per-panel command menu: a cursor-anchored [`ContextMenu`] plus the
14//! Export/Copy format-picker dropdowns it can spawn in place of itself. The
15//! Export/Copy flows are handled end-to-end HERE (the target panel's engines
16//! resolve from the `workspace` prop, like `StatusBar`'s own dropdowns);
17//! every other command is emitted as a [`PanelCommand`] for the parent.
18//!
19//! Both stages are body-mounted [`PortalModal`]s positioned against a shared
20//! session-long cursor anchor, so the menu is themed exactly like the pickers:
21//! the host (`<perspective-context-menu theme="X">`) is matched by the
22//! document theme rules' modal selector groups, with `X` = the TARGET panel's
23//! effective theme (not the active/host theme).
24//!
25//! One menu "session" spans right-click → (menu | picker) → dismissal:
26//! `on_close` fires exactly once, when the session ends (blur dismissal, a
27//! command selection, or the picker closing).
28
29use perspective_js::utils::*;
30use wasm_bindgen::JsCast;
31use web_sys::HtmlElement;
32use yew::prelude::*;
33
34use crate::components::context_menu::{ContextMenu, ContextMenuEntry, ContextMenuItem};
35use crate::components::copy_dropdown::CopyDropDownMenu;
36use crate::components::export_dropdown::ExportDropDownMenu;
37use crate::components::portal::PortalModal;
38use crate::components::style::StyleSurface;
39use crate::config::*;
40use crate::js::copy_to_clipboard;
41use crate::presentation::Presentation;
42use crate::tasks::export_method_to_blob;
43use crate::utils::*;
44use crate::workspace::{PanelId, Workspace};
45
46/// A panel command the menu delegates to its parent. Export/Copy are absent —
47/// they're handled internally by the picker flow.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum PanelCommand {
50    New,
51    NewFrom { client: String, table: String },
52    Duplicate,
53    Reset,
54    Maximize,
55    Restore,
56    ToggleMaster,
57    Close,
58}
59
60/// Which format-picker dropdown the context menu spawned in place of itself.
61#[derive(Clone, Copy, PartialEq)]
62pub enum PickerKind {
63    Export,
64    Copy,
65}
66
67#[derive(Properties)]
68pub struct PanelMenuProps {
69    /// Viewport (client) coordinates the menu (and any spawned picker) anchors
70    /// at.
71    pub x: f64,
72    pub y: f64,
73
74    /// The right-clicked panel this menu targets — or `None` for the
75    /// EMPTY-stage menu (zero panels), which offers only the "New" sub-menu
76    /// (its `NewFrom` items resolve from the loaded-clients registry, no
77    /// panel required).
78    pub panel_id: Option<String>,
79
80    /// For per-panel command context (`is_master`, pivot state, panel count)
81    /// and resolving the target panel's engines for Export/Copy.
82    pub workspace: Workspace,
83
84    /// For `export_method_to_blob`.
85    pub presentation: Presentation,
86
87    /// The TARGET panel's effective theme (its own, else the registry
88    /// default), stamped on both stages' `PortalModal` hosts.
89    pub theme: Option<String>,
90
91    /// Whether the target panel is currently maximized (drives the
92    /// Maximize/Restore item).
93    pub maximized: bool,
94
95    /// A command was selected — the parent executes it (and ends the session
96    /// via the `on_close` that follows every selection).
97    pub on_command: Callback<PanelCommand>,
98
99    /// The menu session ended (backdrop dismissal, command selection, or
100    /// picker close); the parent unmounts this component.
101    pub on_close: Callback<()>,
102}
103
104impl PartialEq for PanelMenuProps {
105    fn eq(&self, rhs: &Self) -> bool {
106        self.x == rhs.x
107            && self.y == rhs.y
108            && self.panel_id == rhs.panel_id
109            && self.theme == rhs.theme
110            && self.maximized == rhs.maximized
111    }
112}
113
114pub enum PanelMenuMsg {
115    /// A parent-executed command was selected.
116    Command(PanelCommand),
117
118    /// Export/Copy was selected: swap the menu for the format picker.
119    OpenPicker(PickerKind),
120
121    /// The `ContextMenu` closed. Fired on backdrop dismissal AND after every
122    /// item selection — swallowed when a picker was just opened (the session
123    /// continues in the picker).
124    MenuClosed,
125
126    /// The picker closed (blur, or a completed export/copy).
127    ClosePicker,
128
129    /// The per-client hosted-table-name fetch (spawned at menu open, feeding
130    /// the "New" sub-menu) resolved: `(client name, its table names)` per
131    /// loaded client.
132    TablesLoaded(Vec<(String, Vec<String>)>),
133}
134
135pub struct PanelMenu {
136    /// The session-long 0×0 cursor anchor element (appended to `<body>`) both
137    /// stages' `PortalModal`s position against; removed on destroy.
138    anchor: HtmlElement,
139
140    /// The open format picker, if the session is in its picker stage.
141    picker: Option<PickerKind>,
142
143    /// The "New" sub-menu's data: `(client name, its hosted table names)` per
144    /// loaded client, in registration order. `None` while the fetch spawned at
145    /// menu open is still in flight.
146    tables: Option<Vec<(String, Vec<String>)>>,
147}
148
149impl Component for PanelMenu {
150    type Message = PanelMenuMsg;
151    type Properties = PanelMenuProps;
152
153    fn create(ctx: &Context<Self>) -> Self {
154        let clients = ctx.props().workspace.clients();
155        let link = ctx.link().clone();
156        ApiFuture::spawn(async move {
157            let mut tables = Vec::with_capacity(clients.len());
158            for client in clients {
159                // A failing client contributes an empty list rather than
160                // poisoning the whole menu.
161                let mut names = match client.get_hosted_table_names().await {
162                    Ok(names) => names,
163                    Err(err) => {
164                        tracing::warn!(
165                            "Failed to list tables for `Client` \"{}\": {err}",
166                            client.get_name()
167                        );
168
169                        vec![]
170                    },
171                };
172
173                // Server-side name order isn't meaningful (or stable) — list
174                // deterministically.
175                names.sort_unstable();
176                tables.push((client.get_name().to_owned(), names));
177            }
178
179            link.send_message(PanelMenuMsg::TablesLoaded(tables));
180            Ok(())
181        });
182
183        Self {
184            anchor: session_anchor(ctx.props().x, ctx.props().y),
185            picker: None,
186            tables: None,
187        }
188    }
189
190    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
191        match msg {
192            PanelMenuMsg::Command(cmd) => {
193                ctx.props().on_command.emit(cmd);
194                false
195            },
196            PanelMenuMsg::OpenPicker(kind) => {
197                self.picker = Some(kind);
198                true
199            },
200            PanelMenuMsg::MenuClosed => {
201                // The menu's `PortalModal` closes (blur) after every item
202                // selection; when that selection just opened a picker, the
203                // session continues — only a plain dismissal/selection ends
204                // it.
205                if self.picker.is_none() {
206                    ctx.props().on_close.emit(());
207                }
208
209                false
210            },
211            PanelMenuMsg::ClosePicker => {
212                ctx.props().on_close.emit(());
213                false
214            },
215            PanelMenuMsg::TablesLoaded(tables) => {
216                self.tables = Some(tables);
217                self.picker.is_none()
218            },
219        }
220    }
221
222    fn view(&self, ctx: &Context<Self>) -> Html {
223        match &self.picker {
224            Some(kind) => self.picker_html(ctx, *kind),
225            None => self.menu_html(ctx),
226        }
227    }
228
229    fn destroy(&mut self, _ctx: &Context<Self>) {
230        // The session ended (or the parent unmounted mid-session, e.g. the
231        // target panel closed); don't leak the cursor anchor.
232        let _ = global::body().remove_child(&self.anchor);
233    }
234}
235
236impl PanelMenu {
237    fn menu_html(&self, ctx: &Context<Self>) -> Html {
238        let on_close = ctx.link().callback(|_| PanelMenuMsg::MenuClosed);
239        let item = |label: &str, on_select: Callback<()>, disabled: bool| {
240            ContextMenuEntry::Item(ContextMenuItem {
241                label: label.to_owned(),
242                on_select,
243                disabled,
244            })
245        };
246        let cmd = |cmd: PanelCommand| {
247            ctx.link()
248                .callback(move |_| PanelMenuMsg::Command(cmd.clone()))
249        };
250        let entries = match ctx.props().panel_id.as_deref() {
251            // The empty-stage menu: no target panel, so only the "New"
252            // sub-menu. Hover-only (`on_select: None`) — plain "New" copies
253            // its source panel's table binding, which doesn't exist here.
254            None => vec![ContextMenuEntry::Submenu {
255                label: "New".to_owned(),
256                on_select: None,
257                entries: self.new_submenu_entries(ctx),
258            }],
259            Some(panel_id) => {
260                let can_close = ctx.props().workspace.len() > 1;
261                let is_master = ctx.props().workspace.is_master(&PanelId::from(panel_id));
262                vec![
263                    ContextMenuEntry::Submenu {
264                        label: "New".to_owned(),
265                        on_select: Some(cmd(PanelCommand::New)),
266                        entries: self.new_submenu_entries(ctx),
267                    },
268                    item("Duplicate", cmd(PanelCommand::Duplicate), false),
269                    item("Reset", cmd(PanelCommand::Reset), false),
270                    item(
271                        "Export",
272                        ctx.link()
273                            .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Export)),
274                        false,
275                    ),
276                    item(
277                        "Copy",
278                        ctx.link()
279                            .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Copy)),
280                        false,
281                    ),
282                    if ctx.props().maximized {
283                        item("Restore", cmd(PanelCommand::Restore), false)
284                    } else {
285                        item("Maximize", cmd(PanelCommand::Maximize), false)
286                    },
287                    // Never gated: masters broadcast from ANY select/click event
288                    // (flat grids fall back to the clicked cell's `==` clause), not
289                    // just a grouped row tree.
290                    item(
291                        if is_master { "Detail" } else { "Master" },
292                        cmd(PanelCommand::ToggleMaster),
293                        false,
294                    ),
295                    item("Close", cmd(PanelCommand::Close), !can_close),
296                ]
297            },
298        };
299
300        html! {
301            <PortalModal
302                tag_name="perspective-context-menu"
303                surface={StyleSurface::ContextMenu}
304                target={Some(self.anchor.clone())}
305                own_focus=true
306                on_close={&on_close}
307                theme={ctx.props().theme.clone().unwrap_or_default()}
308            >
309                // Selection-end and blur-dismissal both route to `MenuClosed`;
310                // duplicates are harmless (the first ends the session or is
311                // swallowed by an open picker).
312                <ContextMenu {entries} {on_close} />
313            </PortalModal>
314        }
315    }
316
317    /// The "New" hover sub-menu's entries: every hosted `Table` name from
318    /// every loaded `Client`, flat for a single client, grouped under a
319    /// header row per client otherwise (client names are globally unique, so
320    /// the grouping also disambiguates table-name collisions).
321    fn new_submenu_entries(&self, ctx: &Context<Self>) -> Vec<ContextMenuEntry> {
322        let placeholder = |label: &str| {
323            vec![ContextMenuEntry::Item(ContextMenuItem {
324                label: label.to_owned(),
325                on_select: Callback::noop(),
326                disabled: true,
327            })]
328        };
329
330        let Some(clients) = &self.tables else {
331            return placeholder("Loading...");
332        };
333
334        if clients.iter().all(|(_, tables)| tables.is_empty()) {
335            return placeholder("No tables");
336        }
337
338        let multi = clients.len() > 1;
339        let mut entries = Vec::new();
340        for (client_name, tables) in clients {
341            if multi {
342                entries.push(ContextMenuEntry::Header(client_name.clone()));
343            }
344
345            for table in tables {
346                let on_select = {
347                    clone!(client_name, table);
348                    ctx.link().callback(move |_| {
349                        PanelMenuMsg::Command(PanelCommand::NewFrom {
350                            client: client_name.clone(),
351                            table: table.clone(),
352                        })
353                    })
354                };
355
356                entries.push(ContextMenuEntry::Item(ContextMenuItem {
357                    label: table.clone(),
358                    on_select,
359                    disabled: false,
360                }));
361            }
362        }
363
364        entries
365    }
366
367    /// Export/Copy format-picker spawned in place of the context menu, anchored
368    /// at the same cursor anchor and reusing the status bar's dropdown
369    /// components.
370    fn picker_html(&self, ctx: &Context<Self>, kind: PickerKind) -> Html {
371        // Export/Copy are absent from the target-less stage menu, so
372        // `panel_id` is always `Some` here in practice.
373        let Some(panel) = ctx
374            .props()
375            .panel_id
376            .as_deref()
377            .and_then(|id| ctx.props().workspace.panel(&PanelId::from(id)))
378        else {
379            return Html::default();
380        };
381
382        let on_close = ctx.link().callback(|_| PanelMenuMsg::ClosePicker);
383        let theme = ctx.props().theme.clone().unwrap_or_default();
384        let target = Some(self.anchor.clone());
385        let presentation = ctx.props().presentation.clone();
386
387        let inner = match kind {
388            PickerKind::Export => {
389                let callback = {
390                    clone!(presentation);
391                    let session = panel.session.clone();
392                    let renderer = panel.renderer.clone();
393                    let link = ctx.link().clone();
394                    Callback::from(move |file: ExportFile| {
395                        if file.name.is_empty() {
396                            return;
397                        }
398
399                        clone!(session, renderer, presentation, link);
400                        ApiFuture::spawn(async move {
401                            let blob = export_method_to_blob(
402                                &session,
403                                &renderer,
404                                &presentation,
405                                file.method,
406                            )
407                            .await?;
408
409                            download(&file.as_filename(renderer.is_chart()), &blob)?;
410                            link.send_message(PanelMenuMsg::ClosePicker);
411                            Ok(())
412                        });
413                    })
414                };
415
416                html! {
417                    <ExportDropDownMenu
418                        renderer={panel.renderer.clone()}
419                        session={panel.session.clone()}
420                        {callback}
421                    />
422                }
423            },
424            PickerKind::Copy => {
425                let callback = {
426                    clone!(presentation);
427                    let session = panel.session.clone();
428                    let renderer = panel.renderer.clone();
429                    let link = ctx.link().clone();
430                    Callback::from(move |file: ExportFile| {
431                        clone!(session, renderer, presentation, link);
432                        ApiFuture::spawn(async move {
433                            let task = export_method_to_blob(
434                                &session,
435                                &renderer,
436                                &presentation,
437                                file.method,
438                            );
439                            copy_to_clipboard(task, file.method.mimetype(file.is_chart)).await?;
440                            link.send_message(PanelMenuMsg::ClosePicker);
441                            Ok(())
442                        });
443                    })
444                };
445
446                html! { <CopyDropDownMenu renderer={panel.renderer.clone()} {callback} /> }
447            },
448        };
449
450        let tag_name = match kind {
451            PickerKind::Export => "perspective-export-menu",
452            PickerKind::Copy => "perspective-copy-menu",
453        };
454
455        html! {
456            <PortalModal
457                {tag_name}
458                surface={StyleSurface::DropdownMenu}
459                {target}
460                own_focus=true
461                {on_close}
462                {theme}
463            >
464                { inner }
465            </PortalModal>
466        }
467    }
468}
469
470/// Create a session-long 0×0 anchor element at viewport `(x, y)`, appended to
471/// `<body>`, for both stages' `PortalModal`s to position against.
472fn session_anchor(x: f64, y: f64) -> HtmlElement {
473    let anchor: HtmlElement = global::document()
474        .create_element("div")
475        .unwrap()
476        .unchecked_into();
477
478    let style = anchor.style();
479    let _ = style.set_property("position", "fixed");
480    let _ = style.set_property("left", &format!("{x}px"));
481    let _ = style.set_property("top", &format!("{y}px"));
482    let _ = style.set_property("width", "0px");
483    let _ = style.set_property("height", "0px");
484    let _ = global::body().append_child(&anchor);
485    anchor
486}