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