use std::rc::Rc;
use perspective_js::utils::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlElement;
use yew::prelude::*;
use crate::components::copy_dropdown::CopyDropDownMenu;
use crate::components::export_dropdown::ExportDropDownMenu;
use crate::components::new_panel_menu::{HostedTables, NewPanelMenu, NewPanelPick};
use crate::components::style::StyleSurface;
use crate::config::*;
use crate::js::copy_to_clipboard;
use crate::presentation::Presentation;
use crate::queries::fetch_hosted_tables;
use crate::tasks::export_method_to_blob;
use crate::ui::{ContextMenu, ContextMenuEntry, ContextMenuItem, MODAL_SLOT, PortalModal};
use crate::utils::*;
use crate::workspace::{PanelId, Workspace};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PanelCommand {
New,
NewFrom {
client: String,
table: String,
},
NewFromPanel(String),
Duplicate,
Reset,
Maximize,
Restore,
ToggleMaster,
Close,
}
#[derive(Clone, Copy, PartialEq)]
pub enum PickerKind {
Export,
Copy,
}
#[derive(Properties)]
pub struct PanelMenuProps {
pub x: f64,
pub y: f64,
pub panel_id: Option<String>,
pub workspace: Workspace,
pub presentation: Presentation,
pub theme: Option<String>,
pub maximized: bool,
pub on_command: Callback<PanelCommand>,
pub on_close: Callback<()>,
}
impl PartialEq for PanelMenuProps {
fn eq(&self, rhs: &Self) -> bool {
self.x == rhs.x
&& self.y == rhs.y
&& self.panel_id == rhs.panel_id
&& self.theme == rhs.theme
&& self.maximized == rhs.maximized
}
}
pub enum PanelMenuMsg {
Command(PanelCommand),
OpenPicker(PickerKind),
MenuClosed,
ClosePicker,
TablesLoaded(Vec<(String, Vec<String>)>),
}
pub struct PanelMenu {
anchor: HtmlElement,
picker: Option<PickerKind>,
tables: Option<HostedTables>,
}
impl Component for PanelMenu {
type Message = PanelMenuMsg;
type Properties = PanelMenuProps;
fn create(ctx: &Context<Self>) -> Self {
let workspace = ctx.props().workspace.clone();
let link = ctx.link().clone();
ApiFuture::spawn(async move {
let tables = fetch_hosted_tables(&workspace).await;
link.send_message(PanelMenuMsg::TablesLoaded(tables));
Ok(())
});
Self {
anchor: session_anchor(
ctx.props().presentation.viewer_elem(),
ctx.props().x,
ctx.props().y,
),
picker: None,
tables: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
PanelMenuMsg::Command(cmd) => {
ctx.props().on_command.emit(cmd);
false
},
PanelMenuMsg::OpenPicker(kind) => {
self.picker = Some(kind);
true
},
PanelMenuMsg::MenuClosed => {
if self.picker.is_none() {
ctx.props().on_close.emit(());
}
false
},
PanelMenuMsg::ClosePicker => {
ctx.props().on_close.emit(());
false
},
PanelMenuMsg::TablesLoaded(tables) => {
self.tables = Some(Rc::new(tables));
self.picker.is_none()
},
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
match &self.picker {
Some(kind) => self.picker_html(ctx, *kind),
None => self.menu_html(ctx),
}
}
fn destroy(&mut self, _ctx: &Context<Self>) {
self.anchor.remove();
}
}
impl PanelMenu {
fn menu_html(&self, ctx: &Context<Self>) -> Html {
let on_close = ctx.link().callback(|_| PanelMenuMsg::MenuClosed);
let item = |label: &str, on_select: Callback<()>, disabled: bool| {
ContextMenuEntry::Item(ContextMenuItem {
label: label.to_owned(),
on_select,
disabled,
})
};
let cmd = |cmd: PanelCommand| {
ctx.link()
.callback(move |_| PanelMenuMsg::Command(cmd.clone()))
};
let entries = match ctx.props().panel_id.as_deref() {
None => vec![ContextMenuEntry::Submenu {
label: "New".to_owned(),
on_select: None,
entries: vec![ContextMenuEntry::Custom(self.new_submenu_body(ctx))],
}],
Some(panel_id) => {
let can_close = ctx.props().workspace.len() > 1;
let is_master = ctx.props().workspace.is_master(&PanelId::from(panel_id));
vec![
ContextMenuEntry::Submenu {
label: "New".to_owned(),
on_select: Some(cmd(PanelCommand::New)),
entries: vec![ContextMenuEntry::Custom(self.new_submenu_body(ctx))],
},
item("Duplicate", cmd(PanelCommand::Duplicate), false),
item("Reset", cmd(PanelCommand::Reset), false),
item(
"Export",
ctx.link()
.callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Export)),
false,
),
item(
"Copy",
ctx.link()
.callback(|_| PanelMenuMsg::OpenPicker(PickerKind::Copy)),
false,
),
if ctx.props().maximized {
item("Restore", cmd(PanelCommand::Restore), false)
} else {
item("Maximize", cmd(PanelCommand::Maximize), false)
},
item(
if is_master { "Detail" } else { "Master" },
cmd(PanelCommand::ToggleMaster),
false,
),
item("Close", cmd(PanelCommand::Close), !can_close),
]
},
};
html! {
<PortalModal
key="perspective-context-menu"
tag_name="perspective-context-menu"
sheet={StyleSurface::ContextMenu.sheet()}
target={Some(self.anchor.clone())}
own_focus=true
on_close={&on_close}
theme={ctx.props().theme.clone().unwrap_or_default()}
>
<ContextMenu {entries} {on_close} />
</PortalModal>
}
}
fn new_submenu_body(&self, ctx: &Context<Self>) -> Html {
let panels = Rc::new(
ctx.props()
.workspace
.panel_ids()
.into_iter()
.filter_map(|id| {
let panel = ctx.props().workspace.panel(&id)?;
let title = panel.session.get_title().filter(|t| !t.is_empty());
Some((id.as_str().to_owned(), title))
})
.collect::<Vec<_>>(),
);
let callback = ctx.link().batch_callback(|pick: NewPanelPick| {
let cmd = match pick {
NewPanelPick::FromTable { client, table } => {
PanelMenuMsg::Command(PanelCommand::NewFrom { client, table })
},
NewPanelPick::FromPanel(id) => {
PanelMenuMsg::Command(PanelCommand::NewFromPanel(id))
},
};
vec![cmd, PanelMenuMsg::MenuClosed]
});
html! { <NewPanelMenu tables={self.tables.clone()} {panels} {callback} /> }
}
fn picker_html(&self, ctx: &Context<Self>, kind: PickerKind) -> Html {
let Some(panel) = ctx
.props()
.panel_id
.as_deref()
.and_then(|id| ctx.props().workspace.panel(&PanelId::from(id)))
else {
return Html::default();
};
let on_close = ctx.link().callback(|_| PanelMenuMsg::ClosePicker);
let theme = ctx.props().theme.clone().unwrap_or_default();
let target = Some(self.anchor.clone());
let presentation = ctx.props().presentation.clone();
let inner = match kind {
PickerKind::Export => {
let callback = {
clone!(presentation);
let session = panel.session.clone();
let renderer = panel.renderer.clone();
let link = ctx.link().clone();
Callback::from(move |file: ExportFile| {
if file.name.is_empty() {
return;
}
clone!(session, renderer, presentation, link);
ApiFuture::spawn(async move {
let blob = export_method_to_blob(
&session,
&renderer,
&presentation,
file.method,
)
.await?;
download(&file.as_filename(renderer.is_chart()), &blob)?;
link.send_message(PanelMenuMsg::ClosePicker);
Ok(())
});
})
};
html! {
<ExportDropDownMenu
renderer={panel.renderer.clone()}
session={panel.session.clone()}
{callback}
/>
}
},
PickerKind::Copy => {
let callback = {
clone!(presentation);
let session = panel.session.clone();
let renderer = panel.renderer.clone();
let link = ctx.link().clone();
Callback::from(move |file: ExportFile| {
clone!(session, renderer, presentation, link);
ApiFuture::spawn(async move {
let task = export_method_to_blob(
&session,
&renderer,
&presentation,
file.method,
);
copy_to_clipboard(task, file.method.mimetype(file.is_chart)).await?;
link.send_message(PanelMenuMsg::ClosePicker);
Ok(())
});
})
};
html! { <CopyDropDownMenu renderer={panel.renderer.clone()} {callback} /> }
},
};
let tag_name = match kind {
PickerKind::Export => "perspective-export-menu",
PickerKind::Copy => "perspective-copy-menu",
};
html! {
<PortalModal
key={tag_name}
{tag_name}
sheet={StyleSurface::DropdownMenu.sheet()}
{target}
own_focus=true
{on_close}
{theme}
>
{ inner }
</PortalModal>
}
}
}
fn session_anchor(viewer: &HtmlElement, x: f64, y: f64) -> HtmlElement {
let anchor: HtmlElement = global::document()
.create_element("div")
.unwrap()
.unchecked_into();
let _ = anchor.set_attribute("slot", MODAL_SLOT);
let style = anchor.style();
let _ = style.set_property("position", "fixed");
let _ = style.set_property("left", &format!("{x}px"));
let _ = style.set_property("top", &format!("{y}px"));
let _ = style.set_property("width", "0px");
let _ = style.set_property("height", "0px");
let _ = viewer.append_child(&anchor);
anchor
}