use perspective_js::utils::global;
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, MouseEvent, Node};
use yew::prelude::*;
#[derive(Clone, PartialEq)]
pub struct ContextMenuItem {
pub label: String,
pub on_select: Callback<()>,
pub disabled: bool,
}
#[derive(Clone, PartialEq)]
pub enum ContextMenuEntry {
Item(ContextMenuItem),
Header(String),
Custom(Html),
Submenu {
label: String,
on_select: Option<Callback<()>>,
entries: Vec<ContextMenuEntry>,
},
}
#[derive(Properties, PartialEq)]
pub struct ContextMenuProps {
pub entries: Vec<ContextMenuEntry>,
#[prop_or_default]
pub on_close: Callback<()>,
}
pub struct ContextMenu;
impl Component for ContextMenu {
type Message = ();
type Properties = ContextMenuProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let entries = props
.entries
.iter()
.map(|entry| render_entry(entry, &props.on_close));
html! { <div class="context-menu">{ for entries }</div> }
}
}
fn render_entry(entry: &ContextMenuEntry, on_close: &Callback<()>) -> Html {
match entry {
ContextMenuEntry::Item(item) => {
let disabled = item.disabled;
let onclick = {
let on_select = item.on_select.clone();
let on_close = on_close.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
if !disabled {
on_select.emit(());
on_close.emit(());
}
})
};
let mut class = classes!("context-menu-item");
if disabled {
class.push("disabled");
}
html! { <span {class} {onclick}>{ item.label.clone() }</span> }
},
ContextMenuEntry::Header(label) => {
html! { <span class="context-menu-header">{ label.clone() }</span> }
},
ContextMenuEntry::Custom(content) => content.clone(),
ContextMenuEntry::Submenu {
label,
on_select,
entries,
} => {
html! {
<Submenu
label={label.clone()}
on_select={on_select.clone()}
entries={entries.clone()}
on_close={on_close.clone()}
/>
}
},
}
}
#[derive(Properties, PartialEq)]
pub struct SubmenuProps {
pub label: String,
pub on_select: Option<Callback<()>>,
pub entries: Vec<ContextMenuEntry>,
pub on_close: Callback<()>,
}
pub enum SubmenuMsg {
Enter,
Leave,
Measure,
}
pub struct Submenu {
flyout: NodeRef,
open: bool,
flip_x: bool,
flip_y: bool,
}
impl Submenu {
fn decide(&self) -> Option<(bool, bool)> {
let flyout = self.flyout.cast::<HtmlElement>()?;
let item = flyout.parent_element()?;
let flyout_rect = flyout.get_bounding_client_rect();
if flyout_rect.width() == 0.0 {
return None;
}
let window = global::window();
let inner_width = window.inner_width().unwrap().as_f64().unwrap();
let inner_height = window.inner_height().unwrap().as_f64().unwrap();
let item_rect = item.get_bounding_client_rect();
let over_x = inner_width < item_rect.right() + flyout_rect.width();
let over_y = inner_height < item_rect.top() - 1.0 + flyout_rect.height();
let flip_x = over_x && item_rect.left() > inner_width - item_rect.right();
let flip_y = over_y && item_rect.bottom() > inner_height - item_rect.top();
Some((flip_x, flip_y))
}
}
impl Component for Submenu {
type Message = SubmenuMsg;
type Properties = SubmenuProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
flyout: NodeRef::default(),
open: false,
flip_x: false,
flip_y: false,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
SubmenuMsg::Enter => {
let changed = !self.open;
self.open = true;
changed
},
SubmenuMsg::Leave => {
let changed = self.open;
self.open = false;
changed
},
SubmenuMsg::Measure => match self.decide() {
Some((flip_x, flip_y)) => {
let changed = (flip_x, flip_y) != (self.flip_x, self.flip_y);
self.flip_x = flip_x;
self.flip_y = flip_y;
changed
},
None => false,
},
}
}
fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
if self.open
&& self
.decide()
.is_some_and(|d| d != (self.flip_x, self.flip_y))
{
ctx.link().send_message(SubmenuMsg::Measure);
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
let onclick = {
let on_select = props.on_select.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
if let Some(on_select) = &on_select {
on_select.emit(());
on_close.emit(());
}
})
};
let onmouseover = ctx.link().callback(|_: MouseEvent| SubmenuMsg::Enter);
let onmouseout = ctx.link().batch_callback({
let flyout = self.flyout.clone();
move |event: MouseEvent| {
let item = flyout.cast::<HtmlElement>()?.parent_element()?;
let related = event
.related_target()
.and_then(|target| target.dyn_into::<Node>().ok());
let stays = related.is_some_and(|node| item.contains(Some(&node)));
(!stays).then_some(SubmenuMsg::Leave)
}
});
let mut class = classes!("context-menu", "context-menu-submenu");
if self.open {
class.push("open");
}
if self.flip_x {
class.push("flip-x");
}
if self.flip_y {
class.push("flip-y");
}
let children = props
.entries
.iter()
.map(|entry| render_entry(entry, &props.on_close));
html! {
<span class="context-menu-item has-submenu" {onclick} {onmouseover} {onmouseout}>
{ props.label.clone() }
<div {class} ref={self.flyout.clone()}>{ for children }</div>
</span>
}
}
}