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