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 key="perspective-context-menu"
303 tag_name="perspective-context-menu"
304 surface={StyleSurface::ContextMenu}
305 target={Some(self.anchor.clone())}
306 own_focus=true
307 on_close={&on_close}
308 theme={ctx.props().theme.clone().unwrap_or_default()}
309 >
310 // Selection-end and blur-dismissal both route to `MenuClosed`;
311 // duplicates are harmless (the first ends the session or is
312 // swallowed by an open picker).
313 <ContextMenu {entries} {on_close} />
314 </PortalModal>
315 }
316 }
317
318 /// The "New" hover sub-menu's entries: every hosted `Table` name from
319 /// every loaded `Client`, flat for a single client, grouped under a
320 /// header row per client otherwise (client names are globally unique, so
321 /// the grouping also disambiguates table-name collisions).
322 fn new_submenu_entries(&self, ctx: &Context<Self>) -> Vec<ContextMenuEntry> {
323 let placeholder = |label: &str| {
324 vec![ContextMenuEntry::Item(ContextMenuItem {
325 label: label.to_owned(),
326 on_select: Callback::noop(),
327 disabled: true,
328 })]
329 };
330
331 let Some(clients) = &self.tables else {
332 return placeholder("Loading...");
333 };
334
335 if clients.iter().all(|(_, tables)| tables.is_empty()) {
336 return placeholder("No tables");
337 }
338
339 let multi = clients.len() > 1;
340 let mut entries = Vec::new();
341 for (client_name, tables) in clients {
342 if multi {
343 entries.push(ContextMenuEntry::Header(client_name.clone()));
344 }
345
346 for table in tables {
347 let on_select = {
348 clone!(client_name, table);
349 ctx.link().callback(move |_| {
350 PanelMenuMsg::Command(PanelCommand::NewFrom {
351 client: client_name.clone(),
352 table: table.clone(),
353 })
354 })
355 };
356
357 entries.push(ContextMenuEntry::Item(ContextMenuItem {
358 label: table.clone(),
359 on_select,
360 disabled: false,
361 }));
362 }
363 }
364
365 entries
366 }
367
368 /// Export/Copy format-picker spawned in place of the context menu, anchored
369 /// at the same cursor anchor and reusing the status bar's dropdown
370 /// components.
371 fn picker_html(&self, ctx: &Context<Self>, kind: PickerKind) -> Html {
372 // Export/Copy are absent from the target-less stage menu, so
373 // `panel_id` is always `Some` here in practice.
374 let Some(panel) = ctx
375 .props()
376 .panel_id
377 .as_deref()
378 .and_then(|id| ctx.props().workspace.panel(&PanelId::from(id)))
379 else {
380 return Html::default();
381 };
382
383 let on_close = ctx.link().callback(|_| PanelMenuMsg::ClosePicker);
384 let theme = ctx.props().theme.clone().unwrap_or_default();
385 let target = Some(self.anchor.clone());
386 let presentation = ctx.props().presentation.clone();
387
388 let inner = match kind {
389 PickerKind::Export => {
390 let callback = {
391 clone!(presentation);
392 let session = panel.session.clone();
393 let renderer = panel.renderer.clone();
394 let link = ctx.link().clone();
395 Callback::from(move |file: ExportFile| {
396 if file.name.is_empty() {
397 return;
398 }
399
400 clone!(session, renderer, presentation, link);
401 ApiFuture::spawn(async move {
402 let blob = export_method_to_blob(
403 &session,
404 &renderer,
405 &presentation,
406 file.method,
407 )
408 .await?;
409
410 download(&file.as_filename(renderer.is_chart()), &blob)?;
411 link.send_message(PanelMenuMsg::ClosePicker);
412 Ok(())
413 });
414 })
415 };
416
417 html! {
418 <ExportDropDownMenu
419 renderer={panel.renderer.clone()}
420 session={panel.session.clone()}
421 {callback}
422 />
423 }
424 },
425 PickerKind::Copy => {
426 let callback = {
427 clone!(presentation);
428 let session = panel.session.clone();
429 let renderer = panel.renderer.clone();
430 let link = ctx.link().clone();
431 Callback::from(move |file: ExportFile| {
432 clone!(session, renderer, presentation, link);
433 ApiFuture::spawn(async move {
434 let task = export_method_to_blob(
435 &session,
436 &renderer,
437 &presentation,
438 file.method,
439 );
440 copy_to_clipboard(task, file.method.mimetype(file.is_chart)).await?;
441 link.send_message(PanelMenuMsg::ClosePicker);
442 Ok(())
443 });
444 })
445 };
446
447 html! { <CopyDropDownMenu renderer={panel.renderer.clone()} {callback} /> }
448 },
449 };
450
451 let tag_name = match kind {
452 PickerKind::Export => "perspective-export-menu",
453 PickerKind::Copy => "perspective-copy-menu",
454 };
455
456 html! {
457 // Keyed by host tag: the menu→picker swap happens at the same
458 // vdom position, and `PortalModal`'s host element + adopted
459 // surface sheet are create-time-only — unkeyed reuse would leave
460 // the picker inside the `<perspective-context-menu>` host with
461 // the context-menu sheet.
462 <PortalModal
463 key={tag_name}
464 {tag_name}
465 surface={StyleSurface::DropdownMenu}
466 {target}
467 own_focus=true
468 {on_close}
469 {theme}
470 >
471 { inner }
472 </PortalModal>
473 }
474 }
475}
476
477/// Create a session-long 0×0 anchor element at viewport `(x, y)`, appended to
478/// `<body>`, for both stages' `PortalModal`s to position against.
479fn session_anchor(x: f64, y: f64) -> HtmlElement {
480 let anchor: HtmlElement = global::document()
481 .create_element("div")
482 .unwrap()
483 .unchecked_into();
484
485 let style = anchor.style();
486 let _ = style.set_property("position", "fixed");
487 let _ = style.set_property("left", &format!("{x}px"));
488 let _ = style.set_property("top", &format!("{y}px"));
489 let _ = style.set_property("width", "0px");
490 let _ = style.set_property("height", "0px");
491 let _ = global::body().append_child(&anchor);
492 anchor
493}