Skip to main content

perspective_viewer/ui/
context_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
13use perspective_js::utils::global;
14use wasm_bindgen::JsCast;
15use web_sys::{HtmlElement, MouseEvent, Node};
16use yew::prelude::*;
17
18/// A single command row in a `ContextMenu`.
19#[derive(Clone, PartialEq)]
20pub struct ContextMenuItem {
21    pub label: String,
22    pub on_select: Callback<()>,
23    pub disabled: bool,
24}
25
26/// A row in a `ContextMenu`: a plain command `ContextMenuItem`, a
27/// non-interactive group label, or a row with a hover-opened sub-menu flyout.
28#[derive(Clone, PartialEq)]
29pub enum ContextMenuEntry {
30    Item(ContextMenuItem),
31    Header(String),
32
33    /// Arbitrary content rendered in place, whose own handlers must close
34    /// the menu.
35    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
52/// The command-item list of a panel context menu.
53pub 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
132/// A hover-opened flyout row that opens toward whichever side of its parent
133/// has room for it, tracked through the bubbling `mouseover`/`mouseout` pair
134/// because Chrome withholds `mouseenter` from listeners on a shadow root.
135pub struct Submenu {
136    flyout: NodeRef,
137    open: bool,
138    flip_x: bool,
139    flip_y: bool,
140}
141
142impl Submenu {
143    /// Which sides the flyout must open toward to stay on-screen, or `None`
144    /// while it is hidden and has no size to measure.
145    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}