Skip to main content

perspective_viewer/components/containers/
split_panel.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 std::cmp::max;
14
15use perspective_js::utils::{ApiResult, global};
16use wasm_bindgen::JsCast;
17use wasm_bindgen::prelude::*;
18use web_sys::HtmlElement;
19use yew::html::Scope;
20use yew::prelude::*;
21
22#[derive(Properties, Default)]
23pub struct SplitPanelProps {
24    pub children: Children,
25
26    #[prop_or_default]
27    pub id: Option<String>,
28
29    #[prop_or_default]
30    pub orientation: Orientation,
31
32    /// Whether to render `<></>` empty templates as empty child panels, or
33    /// omit them entirely.
34    #[prop_or_default]
35    pub skip_empty: bool,
36
37    /// Should the child panels by wrapped in `<div>` elements?
38    #[prop_or_default]
39    pub no_wrap: bool,
40
41    /// Should the panels be rendered/sized in _reverse_ order?
42    #[prop_or_default]
43    pub reverse: bool,
44
45    #[prop_or_default]
46    pub on_reset: Option<Callback<()>>,
47
48    #[prop_or_default]
49    pub on_resize: Option<Callback<(i32, i32)>>,
50
51    #[prop_or_default]
52    pub on_resize_finished: Option<Callback<()>>,
53
54    #[prop_or_default]
55    pub initial_size: Option<i32>,
56}
57
58impl SplitPanelProps {
59    fn validate(&self) -> bool {
60        !self.children.is_empty()
61    }
62}
63
64impl PartialEq for SplitPanelProps {
65    fn eq(&self, other: &Self) -> bool {
66        self.id == other.id
67            && self.children == other.children
68            && self.orientation == other.orientation
69            && self.reverse == other.reverse
70    }
71}
72
73pub enum SplitPanelMsg {
74    StartResizing(usize, i32, i32, HtmlElement),
75    MoveResizing(i32),
76    StopResizing,
77    Reset(usize),
78}
79
80/// A panel with 2 sub panels and a mouse-draggable divider which allows
81/// apportioning the panel's width.
82///
83/// # Examples
84///
85/// ```
86/// html! {
87///     <SplitPanel id="app_panel">
88///         <div id="A">
89///         <div id="B">
90///             <a href=".."></a>
91///         </div>
92///     </SplitPanel>
93/// }
94/// ```
95pub struct SplitPanel {
96    resize_state: Option<ResizingState>,
97    refs: Vec<NodeRef>,
98    styles: Vec<Option<String>>,
99    on_reset: Option<Callback<()>>,
100}
101
102impl Component for SplitPanel {
103    type Message = SplitPanelMsg;
104    type Properties = SplitPanelProps;
105
106    fn create(ctx: &Context<Self>) -> Self {
107        assert!(ctx.props().validate());
108        let len = ctx.props().children.len();
109        // cant just use vec![Default::default(); len] as it would
110        // use the same underlying NodeRef for each element.
111        let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
112
113        let mut styles = vec![Default::default(); len];
114        if let Some(x) = &ctx.props().initial_size {
115            styles[0] = Some(match ctx.props().orientation {
116                Orientation::Horizontal => {
117                    format!("max-width:{x}px;min-width:{x}px;width:{x}px")
118                },
119                Orientation::Vertical => {
120                    format!("max-height:{x}px;min-height:{x}px;height:{x}px")
121                },
122            });
123        }
124
125        Self {
126            resize_state: None,
127            refs,
128            styles,
129            on_reset: None,
130        }
131    }
132
133    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
134        match msg {
135            SplitPanelMsg::Reset(index) => {
136                self.styles[index] = None;
137                self.on_reset.clone_from(&ctx.props().on_reset);
138            },
139            SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
140                let elem = self.refs[index].cast::<HtmlElement>().unwrap();
141                let state =
142                    ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
143
144                self.resize_state = state.ok();
145            },
146            SplitPanelMsg::StopResizing => {
147                self.resize_state = None;
148                if let Some(cb) = &ctx.props().on_resize_finished {
149                    cb.emit(());
150                }
151            },
152            SplitPanelMsg::MoveResizing(client_offset) => {
153                if let Some(state) = self.resize_state.as_ref() {
154                    if let Some(ref cb) = ctx.props().on_resize {
155                        cb.emit(state.get_dimensions(client_offset));
156                    }
157
158                    self.styles[state.index] = state.get_style(client_offset);
159                }
160            },
161        };
162        true
163    }
164
165    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
166        if let Some(on_reset) = self.on_reset.take() {
167            on_reset.emit(());
168        }
169    }
170
171    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
172        assert!(ctx.props().validate());
173        let new_len = ctx.props().children.len();
174        self.refs.resize_with(new_len, Default::default);
175        self.styles.resize(new_len, Default::default());
176        true
177    }
178
179    fn view(&self, ctx: &Context<Self>) -> Html {
180        let iter = ctx
181            .props()
182            .children
183            .iter()
184            .filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
185            .enumerate()
186            .collect::<Vec<_>>();
187
188        let orientation = ctx.props().orientation;
189        let mut classes = classes!("split-panel");
190        if orientation == Orientation::Vertical {
191            classes.push("orient-vertical");
192        }
193
194        if ctx.props().reverse {
195            classes.push("orient-reverse");
196        }
197
198        let count = iter.len();
199        let contents = html! {
200            <>
201                for (i, x) in iter {
202                    if i == 0 {
203                        if count == 1 {
204                            <key=0>
205                                {x}
206                            </>
207                        } else {
208                            <SplitPanelChild
209                                key=0
210                                style={self.styles[i].clone()}
211                                ref_={self.refs[i].clone()}
212                            >
213                                { x }
214                            </SplitPanelChild>
215                        }
216                    } else if i == count - 1 {
217                        <key={i}>
218                            <SplitPanelDivider
219                                i={i - 1}
220                                orientation={ctx.props().orientation}
221                                link={ctx.link().clone()}
222                            />
223                            { x }
224                        </>
225                    } else {
226                        <key={i}>
227                            <SplitPanelDivider
228                                i={i - 1}
229                                orientation={ctx.props().orientation}
230                                link={ctx.link().clone()}
231                            />
232                            <SplitPanelChild
233                                style={self.styles[i].clone()}
234                                ref_={self.refs[i].clone()}
235                            >
236                                { x }
237                            </SplitPanelChild>
238                        </>
239                    }
240                }
241            </>
242        };
243
244        // TODO consider removing this
245        if ctx.props().no_wrap {
246            html! { { contents } }
247        } else {
248            html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
249        }
250    }
251}
252
253#[derive(Clone, Copy, Default, Eq, PartialEq)]
254pub enum Orientation {
255    #[default]
256    Horizontal,
257    Vertical,
258}
259
260#[derive(Properties)]
261struct SplitPanelDividerProps {
262    i: usize,
263    orientation: Orientation,
264    link: Scope<SplitPanel>,
265}
266
267impl PartialEq for SplitPanelDividerProps {
268    fn eq(&self, rhs: &Self) -> bool {
269        self.i == rhs.i && self.orientation == rhs.orientation
270    }
271}
272
273/// The resize handle for a `SplitPanel`.
274#[function_component(SplitPanelDivider)]
275fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
276    let orientation = props.orientation;
277    let i = props.i;
278    let link = props.link.clone();
279    let onmousedown = link.callback(move |event: PointerEvent| {
280        let target = event.target().unwrap().unchecked_into::<HtmlElement>();
281        let pointer_id = event.pointer_id();
282        let size = match orientation {
283            Orientation::Horizontal => event.client_x(),
284            Orientation::Vertical => event.client_y(),
285        };
286
287        SplitPanelMsg::StartResizing(i, size, pointer_id, target)
288    });
289
290    let ondblclick = props.link.callback(move |event: MouseEvent| {
291        event.prevent_default();
292        event.stop_propagation();
293        SplitPanelMsg::Reset(i)
294    });
295
296    // TODO Not sure why, but under some circumstances this can trigger a
297    // `dragstart`, leading to further drag events which cause perspective
298    // havoc.  `event.prevent_default()` in `onmousedown` alternatively fixes
299    // this, but also prevents this event from trigger focus-stealing e.g. from
300    // open dialogs.
301    let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
302
303    html! {
304        <>
305            <div
306                class="split-panel-divider"
307                {ondragstart}
308                onpointerdown={onmousedown}
309                {ondblclick}
310            />
311        </>
312    }
313}
314
315#[derive(Properties, PartialEq)]
316struct SplitPanelChildProps {
317    style: Option<String>,
318    ref_: NodeRef,
319    children: Children,
320}
321
322#[function_component(SplitPanelChild)]
323fn split_panel_child(props: &SplitPanelChildProps) -> Html {
324    let class = if props.style.is_some() {
325        classes!("split-panel-child", "is-width-override")
326    } else {
327        classes!("split-panel-child")
328    };
329    html! {
330        <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
331            { props.children.iter().next().unwrap() }
332        </div>
333    }
334}
335
336/// The state for the `Resizing` action, including the `MouseEvent` callbacks
337/// and panel starting dimensions.
338struct ResizingState {
339    mousemove: Closure<dyn Fn(MouseEvent)>,
340    mouseup: Closure<dyn Fn(MouseEvent)>,
341    cursor: String,
342    index: usize,
343    start: i32,
344    total: i32,
345    alt: i32,
346    orientation: Orientation,
347    reverse: bool,
348    body_style: web_sys::CssStyleDeclaration,
349    pointer_id: i32,
350    pointer_elem: HtmlElement,
351}
352
353impl Drop for ResizingState {
354    /// On `drop`, we must remove these event listeners from the document
355    /// `body`. Without this, the `Closure` objects would not leak, but the
356    /// document will continue to call them, causing runtime exceptions.
357    fn drop(&mut self) {
358        let result: ApiResult<()> = (|| {
359            let mousemove = self.mousemove.as_ref().unchecked_ref();
360            global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
361            let mouseup = self.mouseup.as_ref().unchecked_ref();
362            global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
363            self.release_cursor()?;
364            Ok(())
365        })();
366
367        result.expect("Drop failed")
368    }
369}
370
371/// The minimum size a split panel child can be, including when overridden via
372/// user drag/drop.
373const MINIMUM_SIZE: i32 = 8;
374
375/// When the instantiated, capture the initial dimensions and create the
376/// MouseEvent callbacks.
377impl ResizingState {
378    pub fn new(
379        index: usize,
380        client_offset: i32,
381        ctx: &Context<SplitPanel>,
382        first_elem: &HtmlElement,
383        pointer_id: i32,
384        pointer_elem: HtmlElement,
385    ) -> ApiResult<Self> {
386        let orientation = ctx.props().orientation;
387        let reverse = ctx.props().reverse;
388        let split_panel = ctx.link();
389        let total = match orientation {
390            Orientation::Horizontal => first_elem.offset_width(),
391            Orientation::Vertical => first_elem.offset_height(),
392        };
393
394        let alt = match orientation {
395            Orientation::Horizontal => first_elem.offset_height(),
396            Orientation::Vertical => first_elem.offset_width(),
397        };
398
399        let mouseup = Closure::new({
400            let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
401            move |x| cb.emit(x)
402        });
403
404        let mousemove = Closure::new({
405            let cb = split_panel.callback(move |event: MouseEvent| {
406                SplitPanelMsg::MoveResizing(match orientation {
407                    Orientation::Horizontal => event.client_x(),
408                    Orientation::Vertical => event.client_y(),
409                })
410            });
411
412            move |x| cb.emit(x)
413        });
414
415        let mut state = Self {
416            index,
417            cursor: "".to_owned(),
418            start: client_offset,
419            orientation,
420            reverse,
421            total,
422            alt,
423            body_style: global::body().style(),
424            mouseup,
425            mousemove,
426            pointer_id,
427            pointer_elem,
428        };
429
430        state.capture_cursor()?;
431        state.register_listeners()?;
432        Ok(state)
433    }
434
435    fn get_offset(&self, client_offset: i32) -> i32 {
436        let delta = if self.reverse {
437            self.start - client_offset
438        } else {
439            client_offset - self.start
440        };
441
442        max(MINIMUM_SIZE, self.total + delta)
443    }
444
445    pub fn get_style(&self, client_offset: i32) -> Option<String> {
446        let offset = self.get_offset(client_offset);
447        Some(match self.orientation {
448            Orientation::Horizontal => {
449                format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
450            },
451            Orientation::Vertical => {
452                format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
453            },
454        })
455    }
456
457    pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
458        let offset = self.get_offset(client_offset);
459        match self.orientation {
460            Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
461            Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
462        }
463    }
464
465    /// Adds the event listeners, the corollary of `Drop`.
466    fn register_listeners(&self) -> ApiResult<()> {
467        let mousemove = self.mousemove.as_ref().unchecked_ref();
468        global::body().add_event_listener_with_callback("mousemove", mousemove)?;
469        let mouseup = self.mouseup.as_ref().unchecked_ref();
470        Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
471    }
472
473    /// Helper functions capture and release the global cursor while dragging is
474    /// occurring.
475    fn capture_cursor(&mut self) -> ApiResult<()> {
476        self.pointer_elem.set_pointer_capture(self.pointer_id)?;
477        self.cursor = self.body_style.get_property_value("cursor")?;
478        self.body_style
479            .set_property("cursor", match self.orientation {
480                Orientation::Horizontal => "col-resize",
481                Orientation::Vertical => "row-resize",
482            })?;
483
484        Ok(())
485    }
486
487    /// " but for release
488    fn release_cursor(&self) -> ApiResult<()> {
489        self.pointer_elem.release_pointer_capture(self.pointer_id)?;
490        Ok(self.body_style.set_property("cursor", &self.cursor)?)
491    }
492}