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}