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}