perspective_viewer/ui/
context_menu.rs1use perspective_js::utils::global;
14use wasm_bindgen::JsCast;
15use web_sys::{HtmlElement, MouseEvent, Node};
16use yew::prelude::*;
17
18#[derive(Clone, PartialEq)]
20pub struct ContextMenuItem {
21 pub label: String,
22 pub on_select: Callback<()>,
23 pub disabled: bool,
24}
25
26#[derive(Clone, PartialEq)]
29pub enum ContextMenuEntry {
30 Item(ContextMenuItem),
31 Header(String),
32
33 Custom(Html),
36 Submenu {
37 label: String,
38
39 on_select: Option<Callback<()>>,
40 entries: Vec<ContextMenuEntry>,
41 },
42}
43
44#[derive(Properties, PartialEq)]
45pub struct ContextMenuProps {
46 pub entries: Vec<ContextMenuEntry>,
47
48 #[prop_or_default]
49 pub on_close: Callback<()>,
50}
51
52pub struct ContextMenu;
54
55impl Component for ContextMenu {
56 type Message = ();
57 type Properties = ContextMenuProps;
58
59 fn create(_ctx: &Context<Self>) -> Self {
60 Self
61 }
62
63 fn view(&self, ctx: &Context<Self>) -> Html {
64 let props = ctx.props();
65 let entries = props
66 .entries
67 .iter()
68 .map(|entry| render_entry(entry, &props.on_close));
69
70 html! { <div class="context-menu">{ for entries }</div> }
71 }
72}
73
74fn render_entry(entry: &ContextMenuEntry, on_close: &Callback<()>) -> Html {
75 match entry {
76 ContextMenuEntry::Item(item) => {
77 let disabled = item.disabled;
78 let onclick = {
79 let on_select = item.on_select.clone();
80 let on_close = on_close.clone();
81 Callback::from(move |e: MouseEvent| {
82 e.stop_propagation();
83 if !disabled {
84 on_select.emit(());
85 on_close.emit(());
86 }
87 })
88 };
89
90 let mut class = classes!("context-menu-item");
91 if disabled {
92 class.push("disabled");
93 }
94
95 html! { <span {class} {onclick}>{ item.label.clone() }</span> }
96 },
97 ContextMenuEntry::Header(label) => {
98 html! { <span class="context-menu-header">{ label.clone() }</span> }
99 },
100 ContextMenuEntry::Custom(content) => content.clone(),
101 ContextMenuEntry::Submenu {
102 label,
103 on_select,
104 entries,
105 } => {
106 html! {
107 <Submenu
108 label={label.clone()}
109 on_select={on_select.clone()}
110 entries={entries.clone()}
111 on_close={on_close.clone()}
112 />
113 }
114 },
115 }
116}
117
118#[derive(Properties, PartialEq)]
119pub struct SubmenuProps {
120 pub label: String,
121 pub on_select: Option<Callback<()>>,
122 pub entries: Vec<ContextMenuEntry>,
123 pub on_close: Callback<()>,
124}
125
126pub enum SubmenuMsg {
127 Enter,
128 Leave,
129 Measure,
130}
131
132pub struct Submenu {
136 flyout: NodeRef,
137 open: bool,
138 flip_x: bool,
139 flip_y: bool,
140}
141
142impl Submenu {
143 fn decide(&self) -> Option<(bool, bool)> {
146 let flyout = self.flyout.cast::<HtmlElement>()?;
147 let item = flyout.parent_element()?;
148 let flyout_rect = flyout.get_bounding_client_rect();
149 if flyout_rect.width() == 0.0 {
150 return None;
151 }
152
153 let window = global::window();
154 let inner_width = window.inner_width().unwrap().as_f64().unwrap();
155 let inner_height = window.inner_height().unwrap().as_f64().unwrap();
156 let item_rect = item.get_bounding_client_rect();
157 let over_x = inner_width < item_rect.right() + flyout_rect.width();
158 let over_y = inner_height < item_rect.top() - 1.0 + flyout_rect.height();
159 let flip_x = over_x && item_rect.left() > inner_width - item_rect.right();
160 let flip_y = over_y && item_rect.bottom() > inner_height - item_rect.top();
161 Some((flip_x, flip_y))
162 }
163}
164
165impl Component for Submenu {
166 type Message = SubmenuMsg;
167 type Properties = SubmenuProps;
168
169 fn create(_ctx: &Context<Self>) -> Self {
170 Self {
171 flyout: NodeRef::default(),
172 open: false,
173 flip_x: false,
174 flip_y: false,
175 }
176 }
177
178 fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
179 match msg {
180 SubmenuMsg::Enter => {
181 let changed = !self.open;
182 self.open = true;
183 changed
184 },
185 SubmenuMsg::Leave => {
186 let changed = self.open;
187 self.open = false;
188 changed
189 },
190 SubmenuMsg::Measure => match self.decide() {
191 Some((flip_x, flip_y)) => {
192 let changed = (flip_x, flip_y) != (self.flip_x, self.flip_y);
193 self.flip_x = flip_x;
194 self.flip_y = flip_y;
195 changed
196 },
197 None => false,
198 },
199 }
200 }
201
202 fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
203 if self.open
204 && self
205 .decide()
206 .is_some_and(|d| d != (self.flip_x, self.flip_y))
207 {
208 ctx.link().send_message(SubmenuMsg::Measure);
209 }
210 }
211
212 fn view(&self, ctx: &Context<Self>) -> Html {
213 let props = ctx.props();
214 let onclick = {
215 let on_select = props.on_select.clone();
216 let on_close = props.on_close.clone();
217 Callback::from(move |e: MouseEvent| {
218 e.stop_propagation();
219 if let Some(on_select) = &on_select {
220 on_select.emit(());
221 on_close.emit(());
222 }
223 })
224 };
225
226 let onmouseover = ctx.link().callback(|_: MouseEvent| SubmenuMsg::Enter);
227 let onmouseout = ctx.link().batch_callback({
228 let flyout = self.flyout.clone();
229 move |event: MouseEvent| {
230 let item = flyout.cast::<HtmlElement>()?.parent_element()?;
231 let related = event
232 .related_target()
233 .and_then(|target| target.dyn_into::<Node>().ok());
234
235 let stays = related.is_some_and(|node| item.contains(Some(&node)));
236 (!stays).then_some(SubmenuMsg::Leave)
237 }
238 });
239 let mut class = classes!("context-menu", "context-menu-submenu");
240 if self.open {
241 class.push("open");
242 }
243
244 if self.flip_x {
245 class.push("flip-x");
246 }
247
248 if self.flip_y {
249 class.push("flip-y");
250 }
251
252 let children = props
253 .entries
254 .iter()
255 .map(|entry| render_entry(entry, &props.on_close));
256
257 html! {
258 <span class="context-menu-item has-submenu" {onclick} {onmouseover} {onmouseout}>
259 { props.label.clone() }
260 <div {class} ref={self.flyout.clone()}>{ for children }</div>
261 </span>
262 }
263 }
264}