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