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}