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 => {
139                format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
140            },
141            Orientation::Vertical => {
142                format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
143            },
144        })
145    }
146
147    pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
148        let offset = self.get_offset(client_offset);
149        match self.orientation {
150            Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
151            Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
152        }
153    }
154
155    /// Adds the event listeners, the corollary of `Drop`.
156    fn register_listeners(&self) -> ApiResult<()> {
157        let mousemove = self.mousemove.as_ref().unchecked_ref();
158        global::body().add_event_listener_with_callback("mousemove", mousemove)?;
159        let mouseup = self.mouseup.as_ref().unchecked_ref();
160        Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
161    }
162
163    /// Helper functions capture and release the global cursor while dragging is
164    /// occurring.
165    fn capture_cursor(&mut self) -> ApiResult<()> {
166        self.pointer_elem.set_pointer_capture(self.pointer_id)?;
167        self.cursor = self.body_style.get_property_value("cursor")?;
168        self.body_style
169            .set_property("cursor", match self.orientation {
170                Orientation::Horizontal => "col-resize",
171                Orientation::Vertical => "row-resize",
172            })?;
173
174        Ok(())
175    }
176
177    /// " but for release
178    fn release_cursor(&self) -> ApiResult<()> {
179        self.pointer_elem.release_pointer_capture(self.pointer_id)?;
180        Ok(self.body_style.set_property("cursor", &self.cursor)?)
181    }
182}
183
184#[derive(Clone, Copy, Default, Eq, PartialEq)]
185pub enum Orientation {
186    #[default]
187    Horizontal,
188    Vertical,
189}
190
191#[derive(Properties, Default)]
192pub struct SplitPanelProps {
193    pub children: Children,
194
195    #[prop_or_default]
196    pub id: Option<String>,
197
198    #[prop_or_default]
199    pub orientation: Orientation,
200
201    /// Whether to render `<></>` empty templates as empty child panels, or
202    /// omit them entirely.
203    #[prop_or_default]
204    pub skip_empty: bool,
205
206    /// Should the child panels by wrapped in `<div>` elements?
207    #[prop_or_default]
208    pub no_wrap: bool,
209
210    /// Should the panels be rendered/sized in _reverse_ order?
211    #[prop_or_default]
212    pub reverse: bool,
213
214    #[prop_or_default]
215    pub on_reset: Option<Callback<()>>,
216
217    #[prop_or_default]
218    pub on_resize: Option<Callback<(i32, i32)>>,
219
220    #[prop_or_default]
221    pub on_resize_finished: Option<Callback<()>>,
222
223    #[cfg(test)]
224    #[prop_or_default]
225    pub weak_link: WeakScope<SplitPanel>,
226
227    #[prop_or_default]
228    pub initial_size: Option<i32>,
229}
230
231impl SplitPanelProps {
232    fn validate(&self) -> bool {
233        !self.children.is_empty()
234    }
235}
236
237impl PartialEq for SplitPanelProps {
238    fn eq(&self, other: &Self) -> bool {
239        self.id == other.id
240            && self.children == other.children
241            && self.orientation == other.orientation
242            && self.reverse == other.reverse
243    }
244}
245
246pub enum SplitPanelMsg {
247    StartResizing(usize, i32, i32, HtmlElement),
248    MoveResizing(i32),
249    StopResizing,
250    Reset(usize),
251}
252
253/// A panel with 2 sub panels and a mouse-draggable divider which allows
254/// apportioning the panel's width.
255///
256/// # Examples
257///
258/// ```
259/// html! {
260///     <SplitPanel id="app_panel">
261///         <div id="A">
262///         <div id="B">
263///             <a href=".."></a>
264///         </div>
265///     </SplitPanel>
266/// }
267/// ```
268pub struct SplitPanel {
269    resize_state: Option<ResizingState>,
270    refs: Vec<NodeRef>,
271    styles: Vec<Option<String>>,
272    on_reset: Option<Callback<()>>,
273}
274
275impl Component for SplitPanel {
276    type Message = SplitPanelMsg;
277    type Properties = SplitPanelProps;
278
279    fn create(ctx: &Context<Self>) -> Self {
280        assert!(ctx.props().validate());
281        enable_weak_link_test!(ctx.props(), ctx.link());
282        let len = ctx.props().children.len();
283        // cant just use vec![Default::default(); len] as it would
284        // use the same underlying NodeRef for each element.
285        let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
286
287        let mut styles = vec![Default::default(); len];
288        if let Some(x) = &ctx.props().initial_size {
289            styles[0] = Some(match ctx.props().orientation {
290                Orientation::Horizontal => {
291                    format!("max-width:{x}px;min-width:{x}px;width:{x}px")
292                },
293                Orientation::Vertical => {
294                    format!("max-height:{x}px;min-height:{x}px;height:{x}px")
295                },
296            });
297        }
298
299        Self {
300            resize_state: None,
301            refs,
302            styles,
303            on_reset: None,
304        }
305    }
306
307    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
308        match msg {
309            SplitPanelMsg::Reset(index) => {
310                self.styles[index] = None;
311                self.on_reset.clone_from(&ctx.props().on_reset);
312            },
313            SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
314                let elem = self.refs[index].cast::<HtmlElement>().unwrap();
315                let state =
316                    ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
317
318                self.resize_state = state.ok();
319            },
320            SplitPanelMsg::StopResizing => {
321                self.resize_state = None;
322                if let Some(cb) = &ctx.props().on_resize_finished {
323                    cb.emit(());
324                }
325            },
326            SplitPanelMsg::MoveResizing(client_offset) => {
327                if let Some(state) = self.resize_state.as_ref() {
328                    if let Some(ref cb) = ctx.props().on_resize {
329                        cb.emit(state.get_dimensions(client_offset));
330                    }
331
332                    self.styles[state.index] = state.get_style(client_offset);
333                }
334            },
335        };
336        true
337    }
338
339    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
340        if let Some(on_reset) = self.on_reset.take() {
341            on_reset.emit(());
342        }
343    }
344
345    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
346        assert!(ctx.props().validate());
347        let new_len = ctx.props().children.len();
348        self.refs.resize_with(new_len, Default::default);
349        self.styles.resize(new_len, Default::default());
350        true
351    }
352
353    fn view(&self, ctx: &Context<Self>) -> Html {
354        let mut iter = ctx.props().children.iter();
355        let orientation = ctx.props().orientation;
356        let mut classes = classes!("split-panel");
357        if orientation == Orientation::Vertical {
358            classes.push("orient-vertical");
359        }
360
361        if ctx.props().reverse {
362            classes.push("orient-reverse");
363        }
364
365        let head = iter.next().unwrap();
366
367        let tail = iter
368            .filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
369            .enumerate()
370            .map(|(i, x)| {
371                html! {
372                    <key={i + 2}>
373                        <SplitPanelDivider
374                            {i}
375                            orientation={ctx.props().orientation}
376                            link={ctx.link().clone()}
377                        />
378                        if i == ctx.props().children.len() - 2 { { x } } else {
379                            <SplitPanelChild
380                                style={self.styles[i + 1].clone()}
381                                ref_={self.refs[i + 1].clone()}
382                            >
383                                { x }
384                            </SplitPanelChild>
385                        }
386                    </>
387                }
388            });
389
390        let contents = html! {
391            <>
392                <LocalStyle key=0 href={css!("containers/split-panel")} />
393                <SplitPanelChild key=1 style={self.styles[0].clone()} ref_={self.refs[0].clone()}>
394                    { head }
395                </SplitPanelChild>
396                { for tail }
397            </>
398        };
399
400        // TODO consider removing this
401        if ctx.props().no_wrap {
402            html! { { contents } }
403        } else {
404            html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
405        }
406    }
407}
408
409#[derive(Properties)]
410struct SplitPanelDividerProps {
411    i: usize,
412    orientation: Orientation,
413    link: Scope<SplitPanel>,
414}
415
416impl PartialEq for SplitPanelDividerProps {
417    fn eq(&self, rhs: &Self) -> bool {
418        self.i == rhs.i && self.orientation == rhs.orientation
419    }
420}
421
422/// The resize handle for a `SplitPanel`.
423#[function_component(SplitPanelDivider)]
424fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
425    let orientation = props.orientation;
426    let i = props.i;
427    let link = props.link.clone();
428    let onmousedown = link.callback(move |event: PointerEvent| {
429        let target = event.target().unwrap().unchecked_into::<HtmlElement>();
430        let pointer_id = event.pointer_id();
431        let size = match orientation {
432            Orientation::Horizontal => event.client_x(),
433            Orientation::Vertical => event.client_y(),
434        };
435
436        SplitPanelMsg::StartResizing(i, size, pointer_id, target)
437    });
438
439    let ondblclick = props.link.callback(move |event: MouseEvent| {
440        event.prevent_default();
441        event.stop_propagation();
442        SplitPanelMsg::Reset(i)
443    });
444
445    // TODO Not sure why, but under some circumstances this can trigger a
446    // `dragstart`, leading to further drag events which cause perspective
447    // havoc.  `event.prevent_default()` in `onmousedown` alternatively fixes
448    // this, but also prevents this event from trigger focus-stealing e.g. from
449    // open dialogs.
450    let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
451
452    html! {
453        <>
454            <div
455                class="split-panel-divider"
456                {ondragstart}
457                onpointerdown={onmousedown}
458                {ondblclick}
459            />
460        </>
461    }
462}
463
464#[derive(Properties, PartialEq)]
465struct SplitPanelChildProps {
466    style: Option<String>,
467    ref_: NodeRef,
468    children: Children,
469}
470
471#[function_component(SplitPanelChild)]
472fn split_panel_child(props: &SplitPanelChildProps) -> Html {
473    let class = if props.style.is_some() {
474        classes!("split-panel-child", "is-width-override")
475    } else {
476        classes!("split-panel-child")
477    };
478    html! {
479        <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
480            { props.children.iter().next().unwrap() }
481        </div>
482    }
483}