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 std::rc::Rc;
30
31use perspective_js::utils::*;
32use wasm_bindgen::JsCast;
33use web_sys::HtmlElement;
34use yew::prelude::*;
35
36use crate::components::copy_dropdown::CopyDropDownMenu;
37use crate::components::export_dropdown::ExportDropDownMenu;
38use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick};
39use crate::components::style::StyleSurface;
40use crate::config::*;
41use crate::js::copy_to_clipboard;
42use crate::presentation::Presentation;
43use crate::queries::fetch_hosted_tables;
44use crate::tasks::export_method_to_blob;
45use crate::ui::{ContextMenu, ContextMenuEntry, ContextMenuItem, MODAL_SLOT, PortalModal};
46use crate::utils::*;
47use crate::workspace::{PanelId, Workspace};
48
49/// A panel command the menu delegates to its parent. Export/Copy are absent —
50/// they're handled internally by the picker flow.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum PanelCommand {
53    New,
54    NewFrom {
55        client: String,
56        table: String,
57    },
58
59    /// A fresh panel copied from the named panel.
60    NewFromPanel(String),
61    Duplicate,
62    Reset,
63    Maximize,
64    Restore,
65    ToggleMaster,
66    Close,
67}
68
69/// Which format-picker dropdown the context menu spawned in place of itself.
70#[derive(Clone, Copy, PartialEq)]
71pub enum PickerKind {
72    Export,
73    Copy,
74}
75
76#[derive(Properties)]
77pub struct PanelMenuProps {
78    /// Viewport (client) coordinates the menu (and any spawned picker) anchors
79    /// at.
80    pub x: f64,
81    pub y: f64,
82
83    /// The right-clicked panel this menu targets — or `None` for the
84    /// EMPTY-stage menu (zero panels), which offers only the "New" sub-menu
85    /// (its `NewFrom` items resolve from the loaded-clients registry, no
86    /// panel required).
87    pub panel_id: Option<String>,
88
89    /// For per-panel command context (`is_master`, pivot state, panel count)
90    /// and resolving the target panel's engines for Export/Copy.
91    pub workspace: Workspace,
92
93    /// For `export_method_to_blob`.
94    pub presentation: Presentation,
95
96    /// The TARGET panel's effective theme (its own, else the registry
97    /// default), stamped on both stages' `PortalModal` hosts.
98    pub theme: Option<String>,
99
100    /// Whether the target panel is currently maximized (drives the
101    /// Maximize/Restore item).
102    pub maximized: bool,
103
104    /// A command was selected — the parent executes it (and ends the session
105    /// via the `on_close` that follows every selection).
106    pub on_command: Callback<PanelCommand>,
107
108    /// The menu session ended (backdrop dismissal, command selection, or
109    /// picker close); the parent unmounts this component.
110    pub on_close: Callback<()>,
111}
112
113impl PartialEq for PanelMenuProps {
114    fn eq(&self, rhs: &Self) -> bool {
115        self.x == rhs.x
116            && self.y == rhs.y
117            && self.panel_id == rhs.panel_id
118            && self.theme == rhs.theme
119            && self.maximized == rhs.maximized
120    }
121}
122
123pub enum PanelMenuMsg {
124    /// A parent-executed command was selected.
125    Command(PanelCommand),
126
127    /// Export/Copy was selected: swap the menu for the format picker.
128    OpenPicker(PickerKind),
129
130    /// The `ContextMenu` closed. Fired on backdrop dismissal AND after every
131    /// item selection — swallowed when a picker was just opened (the session
132    /// continues in the picker).
133    MenuClosed,
134
135    /// The picker closed (blur, or a completed export/copy).
136    ClosePicker,
137
138    /// The per-client hosted-table-name fetch (spawned at menu open, feeding
139    /// the "New" sub-menu) resolved: `(client name, its table names)` per
140    /// loaded client.
141    TablesLoaded(Vec<(String, Vec<String>)>),
142}
143
144pub struct PanelMenu {
145    /// The session-long 0×0 cursor anchor element (a viewer light-DOM child)
146    /// both stages' `PortalModal`s position against; removed on destroy.
147    anchor: HtmlElement,
148
149    /// The open format picker, if the session is in its picker stage.
150    picker: Option<PickerKind>,
151
152    /// The "New" sub-menu's data: `(client name, its hosted table names)` per
153    /// loaded client, in registration order. `None` while the fetch spawned at
154    /// menu open is still in flight.
155    tables: Option<HostedTables>,
156}
157
158impl Component for PanelMenu {
159    type Message = PanelMenuMsg;
160    type Properties = PanelMenuProps;
161
162    fn create(ctx: &Context<Self>) -> Self {
163        let workspace = ctx.props().workspace.clone();
164        let link = ctx.link().clone();
165        ApiFuture::spawn(async move {
166            let tables = fetch_hosted_tables(&workspace).await;
167            link.send_message(PanelMenuMsg::TablesLoaded(tables));
168            Ok(())
169        });
170
171        Self {
172            anchor: session_anchor(
173                ctx.props().presentation.viewer_elem(),
174                ctx.props().x,
175                ctx.props().y,
176            ),
177            picker: None,
178            tables: None,
179        }
180    }
181
182    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
183        match msg {
184            PanelMenuMsg::Command(cmd) => {
185                ctx.props().on_command.emit(cmd);
186                false
187            },
188            PanelMenuMsg::OpenPicker(kind) => {
189                self.picker = Some(kind);
190                true
191            },
192            PanelMenuMsg::MenuClosed => {
193                // The menu's `PortalModal` closes (blur) after every item
194                // selection; when that selection just opened a picker, the
195                // session continues — only a plain dismissal/selection ends
196                // it.
197                if self.picker.is_none() {
198                    ctx.props().on_close.emit(());
199                }
200
201                false
202            },
203            PanelMenuMsg::ClosePicker => {
204                ctx.props().on_close.emit(());
205                false
206            },
207            PanelMenuMsg::TablesLoaded(tables) => {
208                self.tables = Some(Rc::new(tables));
209                self.picker.is_none()
210            },
211        }
212    }
213
214    fn view(&self, ctx: &Context<Self>) -> Html {
215        match &self.picker {
216            Some(kind) => self.picker_html(ctx, *kind),
217            None => self.menu_html(ctx),
218        }
219    }
220
221    fn destroy(&mut self, _ctx: &Context<Self>) {
222        // The session ended (or the parent unmounted mid-session, e.g. the
223        // target panel closed); don't leak the cursor anchor.
224        self.anchor.remove();
225    }
226}
227
228impl PanelMenu {
229    fn menu_html(&self, ctx: &Context<Self>) -> Html {
230        let on_close = ctx.link().callback(|_| PanelMenuMsg::MenuClosed);
231        let item = |label: &str, on_select: Callback<()>, disabled: bool| {
232            ContextMenuEntry::Item(ContextMenuItem {
233                label: label.to_owned(),
234                on_select,
235                disabled,
236            })
237        };
238        let cmd = |cmd: PanelCommand| {
239            ctx.link()
240                .callback(move |_| PanelMenuMsg::Command(cmd.clone()))
241        };
242        let entries = match ctx.props().panel_id.as_deref() {
243            // The empty-stage menu: no target panel, so only the "New"
244            // sub-menu. Hover-only (`on_select: None`) — plain "New" copies
245            // its source panel's table binding, which doesn't exist here.
246            None => vec![ContextMenuEntry::Submenu {
247                label: "New".to_owned(),
248                on_select: None,
249                entries: vec![ContextMenuEntry::Custom(self.new_submenu_body(ctx))],
250            }],
251            Some(panel_id) => {
252                let can_close = ctx.props().workspace.len() > 1;
253                let is_master = ctx.props().workspace.is_master(&PanelId::from(panel_id));
254                vec![
255                    ContextMenuEntry::Submenu {
256                        label: "New".to_owned(),
257                        on_select: Some(cmd(PanelCommand::New)),
258                        entries: vec![ContextMenuEntry::Custom(self.new_submenu_body(ctx))],
259                    },
260                    item("Duplicate", cmd(PanelCommand::Duplicate), false),
261                    item("Reset", cmd(PanelCommand::Reset), false),
262                    item(
263                        "Export",
264                        ctx.link()
265                            .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Export)),
266                        false,
267                    ),
268                    item(
269                        "Copy",
270                        ctx.link()
271                            .callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Copy)),
272                        false,
273                    ),
274                    if ctx.props().maximized {
275                        item("Restore", cmd(PanelCommand::Restore), false)
276                    } else {
277                        item("Maximize", cmd(PanelCommand::Maximize), false)
278                    },
279                    // Never gated: masters broadcast from ANY select/click event
280                    // (flat grids fall back to the clicked cell's `==` clause), not
281                    // just a grouped row tree.
282                    item(
283                        if is_master { "Detail" } else { "Master" },
284                        cmd(PanelCommand::ToggleMaster),
285                        false,
286                    ),
287                    item("Close", cmd(PanelCommand::Close), !can_close),
288                ]
289            },
290        };
291
292        html! {
293            <PortalModal
294                key="perspective-context-menu"
295                tag_name="perspective-context-menu"
296                sheet={StyleSurface::ContextMenu.sheet()}
297                target={Some(self.anchor.clone())}
298                own_focus=true
299                on_close={&on_close}
300                theme={ctx.props().theme.clone().unwrap_or_default()}
301            >
302                // Selection-end and blur-dismissal both route to `MenuClosed`;
303                // duplicates are harmless (the first ends the session or is
304                // swallowed by an open picker).
305                <ContextMenu {entries} {on_close} />
306            </PortalModal>
307        }
308    }
309
310    /// The "New" hover sub-menu's body, the shared [`NewPanelMenu`].
311    fn new_submenu_body(&self, ctx: &Context<Self>) -> Html {
312        let panels = Rc::new(
313            ctx.props()
314                .workspace
315                .panel_ids()
316                .into_iter()
317                .filter_map(|id| {
318                    let panel = ctx.props().workspace.panel(&id)?;
319                    let title = panel.session.get_title().filter(|t| !t.is_empty());
320                    Some((id.as_str().to_owned(), title))
321                })
322                .collect::<Vec<_>>(),
323        );
324
325        let callback = ctx.link().batch_callback(|pick: NewPanelPick| {
326            let cmd = match pick {
327                NewPanelPick::FromTable { client, table } => {
328                    PanelMenuMsg::Command(PanelCommand::NewFrom { client, table })
329                },
330                NewPanelPick::FromPanel(id) => {
331                    PanelMenuMsg::Command(PanelCommand::NewFromPanel(id))
332                },
333            };
334
335            vec![cmd, PanelMenuMsg::MenuClosed]
336        });
337
338        html! { <NewPanelMenu tables={self.tables.clone()} {panels} {callback} /> }
339    }
340
341    /// Export/Copy format-picker spawned in place of the context menu, anchored
342    /// at the same cursor anchor and reusing the status bar's dropdown
343    /// components.
344    fn picker_html(&self, ctx: &Context<Self>, kind: PickerKind) -> Html {
345        // Export/Copy are absent from the target-less stage menu, so
346        // `panel_id` is always `Some` here in practice.
347        let Some(panel) = ctx
348            .props()
349            .panel_id
350            .as_deref()
351            .and_then(|id| ctx.props().workspace.panel(&PanelId::from(id)))
352        else {
353            return Html::default();
354        };
355
356        let on_close = ctx.link().callback(|_| PanelMenuMsg::ClosePicker);
357        let theme = ctx.props().theme.clone().unwrap_or_default();
358        let target = Some(self.anchor.clone());
359        let presentation = ctx.props().presentation.clone();
360
361        let inner = match kind {
362            PickerKind::Export => {
363                let callback = {
364                    clone!(presentation);
365                    let session = panel.session.clone();
366                    let renderer = panel.renderer.clone();
367                    let link = ctx.link().clone();
368                    Callback::from(move |file: ExportFile| {
369                        if file.name.is_empty() {
370                            return;
371                        }
372
373                        clone!(session, renderer, presentation, link);
374                        ApiFuture::spawn(async move {
375                            let blob = export_method_to_blob(
376                                &session,
377                                &renderer,
378                                &presentation,
379                                file.method,
380                            )
381                            .await?;
382
383                            download(&file.as_filename(renderer.is_chart()), &blob)?;
384                            link.send_message(PanelMenuMsg::ClosePicker);
385                            Ok(())
386                        });
387                    })
388                };
389
390                html! {
391                    <ExportDropDownMenu
392                        renderer={panel.renderer.clone()}
393                        session={panel.session.clone()}
394                        {callback}
395                    />
396                }
397            },
398            PickerKind::Copy => {
399                let callback = {
400                    clone!(presentation);
401                    let session = panel.session.clone();
402                    let renderer = panel.renderer.clone();
403                    let link = ctx.link().clone();
404                    Callback::from(move |file: ExportFile| {
405                        clone!(session, renderer, presentation, link);
406                        ApiFuture::spawn(async move {
407                            let task = export_method_to_blob(
408                                &session,
409                                &renderer,
410                                &presentation,
411                                file.method,
412                            );
413                            copy_to_clipboard(task, file.method.mimetype(file.is_chart)).await?;
414                            link.send_message(PanelMenuMsg::ClosePicker);
415                            Ok(())
416                        });
417                    })
418                };
419
420                html! { <CopyDropDownMenu renderer={panel.renderer.clone()} {callback} /> }
421            },
422        };
423
424        let tag_name = match kind {
425            PickerKind::Export => "perspective-export-menu",
426            PickerKind::Copy => "perspective-copy-menu",
427        };
428
429        html! {
430            // Keyed by host tag: the menu→picker swap happens at the same
431            // vdom position, and `PortalModal`'s host element + adopted
432            // surface sheet are create-time-only — unkeyed reuse would leave
433            // the picker inside the `<perspective-context-menu>` host with
434            // the context-menu sheet.
435            <PortalModal
436                key={tag_name}
437                {tag_name}
438                sheet={StyleSurface::DropdownMenu.sheet()}
439                {target}
440                own_focus=true
441                {on_close}
442                {theme}
443            >
444                { inner }
445            </PortalModal>
446        }
447    }
448}
449
450/// Create the session-long 0×0 cursor anchor at viewport `(x, y)` as a
451/// light-DOM child of `viewer` in the modal slot.
452fn session_anchor(viewer: &HtmlElement, x: f64, y: f64) -> HtmlElement {
453    let anchor: HtmlElement = global::document()
454        .create_element("div")
455        .unwrap()
456        .unchecked_into();
457
458    let _ = anchor.set_attribute("slot", MODAL_SLOT);
459    let style = anchor.style();
460    let _ = style.set_property("position", "fixed");
461    let _ = style.set_property("left", &format!("{x}px"));
462    let _ = style.set_property("top", &format!("{y}px"));
463    let _ = style.set_property("width", "0px");
464    let _ = style.set_property("height", "0px");
465    let _ = viewer.append_child(&anchor);
466    anchor
467}