perspective_viewer/components/containers/
split_panel.rs1use 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]
31 pub class: Classes,
32
33 #[prop_or_default]
34 pub orientation: Orientation,
35
36 #[prop_or_default]
41 pub skip_empty: bool,
42
43 #[prop_or_default]
45 pub no_wrap: bool,
46
47 #[prop_or_default]
49 pub reverse: bool,
50
51 #[prop_or_default]
52 pub on_reset: Option<Callback<()>>,
53
54 #[prop_or_default]
55 pub on_resize: Option<Callback<(i32, i32)>>,
56
57 #[prop_or_default]
58 pub on_resize_finished: Option<Callback<()>>,
59
60 #[prop_or_default]
61 pub initial_size: Option<i32>,
62
63 #[prop_or_default]
72 pub deferred: bool,
73
74 #[prop_or_default]
78 pub size: Option<i32>,
79}
80
81fn is_empty_html(node: &Html) -> bool {
89 match node {
90 Html::VList(list) => list.iter().all(is_empty_html),
91 _ => false,
92 }
93}
94
95fn size_style(orientation: Orientation, x: i32) -> String {
98 match orientation {
99 Orientation::Horizontal => {
100 format!("max-width:{x}px;min-width:{x}px;width:{x}px")
101 },
102 Orientation::Vertical => {
103 format!("max-height:{x}px;min-height:{x}px;height:{x}px")
104 },
105 }
106}
107
108impl SplitPanelProps {
109 fn validate(&self) -> bool {
110 !self.children.is_empty()
111 }
112}
113
114impl PartialEq for SplitPanelProps {
115 fn eq(&self, other: &Self) -> bool {
116 self.id == other.id
117 && self.class == other.class
118 && self.children == other.children
119 && self.orientation == other.orientation
120 && self.reverse == other.reverse
121 && self.size == other.size
122 && self.deferred == other.deferred
123 }
124}
125
126pub enum SplitPanelMsg {
127 StartResizing(usize, i32, i32, HtmlElement),
128 MoveResizing(i32),
129 StopResizing,
130 Reset(usize),
131}
132
133pub struct SplitPanel {
149 resize_state: Option<ResizingState>,
150 refs: Vec<NodeRef>,
151 styles: Vec<Option<String>>,
152 on_reset: Option<Callback<()>>,
153}
154
155impl Component for SplitPanel {
156 type Message = SplitPanelMsg;
157 type Properties = SplitPanelProps;
158
159 fn create(ctx: &Context<Self>) -> Self {
160 assert!(ctx.props().validate());
161 let len = ctx.props().children.len();
162 let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
165
166 let mut styles = vec![Default::default(); len];
167 if let Some(x) = ctx.props().size.or(ctx.props().initial_size) {
168 styles[0] = Some(size_style(ctx.props().orientation, x));
169 }
170
171 Self {
172 resize_state: None,
173 refs,
174 styles,
175 on_reset: None,
176 }
177 }
178
179 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
180 match msg {
181 SplitPanelMsg::Reset(index) => {
182 self.styles[index] = None;
183 self.on_reset.clone_from(&ctx.props().on_reset);
184 },
185 SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
186 let elem = self.refs[index].cast::<HtmlElement>().unwrap();
187 let state =
188 ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
189
190 self.resize_state = state.ok();
191 },
192 SplitPanelMsg::StopResizing => {
193 self.resize_state = None;
194 if let Some(cb) = &ctx.props().on_resize_finished {
195 cb.emit(());
196 }
197 },
198 SplitPanelMsg::MoveResizing(client_offset) => {
199 if let Some(state) = self.resize_state.as_ref() {
200 if let Some(ref cb) = ctx.props().on_resize {
201 cb.emit(state.get_dimensions(client_offset));
202 }
203
204 if !ctx.props().deferred {
208 self.styles[state.index] = state.get_style(client_offset);
209 }
210 }
211 },
212 };
213 true
214 }
215
216 fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
217 if let Some(on_reset) = self.on_reset.take() {
218 on_reset.emit(());
219 }
220 }
221
222 fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
223 assert!(ctx.props().validate());
224 let new_len = ctx.props().children.len();
225 self.refs.resize_with(new_len, Default::default);
226 self.styles.resize(new_len, Default::default());
227
228 if let Some(state) = self.resize_state.as_ref() {
229 let skip_empty = ctx.props().skip_empty;
230 let still_visible = ctx
231 .props()
232 .children
233 .iter()
234 .enumerate()
235 .any(|(i, x)| i == state.index && (!skip_empty || !is_empty_html(&x)));
236
237 if !still_visible {
238 self.resize_state = None;
239 }
240 }
241
242 if ctx.props().deferred {
245 self.styles[0] = ctx
246 .props()
247 .size
248 .map(|x| size_style(ctx.props().orientation, x));
249 }
250
251 true
252 }
253
254 fn view(&self, ctx: &Context<Self>) -> Html {
255 let skip_empty = ctx.props().skip_empty;
256 let orientation = ctx.props().orientation;
257
258 let panes = ctx
268 .props()
269 .children
270 .iter()
271 .enumerate()
272 .filter(|(_, x)| !skip_empty || !is_empty_html(x))
273 .collect::<Vec<_>>();
274
275 let last = panes.len().saturating_sub(1);
276 let mut nodes: Vec<Html> = Vec::with_capacity(panes.len() * 2);
277 let mut prev: Option<usize> = None;
278 for (pos, (i, x)) in panes.into_iter().enumerate() {
279 if let Some(p) = prev {
282 nodes.push(html! {
283 <SplitPanelDivider
284 key={format!("divider-{i}")}
285 i={p}
286 {orientation}
287 link={ctx.link().clone()}
288 />
289 });
290 }
291
292 nodes.push(if pos == last {
296 html! { <key={i}>{ x }</> }
297 } else {
298 html! {
299 <SplitPanelChild
300 key={i}
301 style={self.styles[i].clone()}
302 ref_={self.refs[i].clone()}
303 >
304 { x }
305 </SplitPanelChild>
306 }
307 });
308
309 prev = Some(i);
310 }
311
312 let mut classes = classes!("split-panel");
313 classes.extend(ctx.props().class.clone());
314 if orientation == Orientation::Vertical {
315 classes.push("orient-vertical");
316 }
317
318 if ctx.props().reverse {
319 classes.push("orient-reverse");
320 }
321
322 let contents = html! { <>{ for nodes.into_iter() }</> };
323 if ctx.props().no_wrap {
324 html! { { contents } }
325 } else {
326 html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
327 }
328 }
329}
330
331#[derive(Clone, Copy, Default, Eq, PartialEq)]
332pub enum Orientation {
333 #[default]
334 Horizontal,
335 Vertical,
336}
337
338#[derive(Properties)]
339struct SplitPanelDividerProps {
340 i: usize,
341 orientation: Orientation,
342 link: Scope<SplitPanel>,
343}
344
345impl PartialEq for SplitPanelDividerProps {
346 fn eq(&self, rhs: &Self) -> bool {
347 self.i == rhs.i && self.orientation == rhs.orientation
348 }
349}
350
351#[function_component(SplitPanelDivider)]
353fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
354 let orientation = props.orientation;
355 let i = props.i;
356 let link = props.link.clone();
357 let onmousedown = link.callback(move |event: PointerEvent| {
358 let target = event.target().unwrap().unchecked_into::<HtmlElement>();
359 let pointer_id = event.pointer_id();
360 let size = match orientation {
361 Orientation::Horizontal => event.client_x(),
362 Orientation::Vertical => event.client_y(),
363 };
364
365 SplitPanelMsg::StartResizing(i, size, pointer_id, target)
366 });
367
368 let ondblclick = props.link.callback(move |event: MouseEvent| {
369 event.prevent_default();
370 event.stop_propagation();
371 SplitPanelMsg::Reset(i)
372 });
373
374 let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
380
381 html! {
382 <>
383 <div
384 class="split-panel-divider"
385 {ondragstart}
386 onpointerdown={onmousedown}
387 {ondblclick}
388 />
389 </>
390 }
391}
392
393#[derive(Properties, PartialEq)]
394struct SplitPanelChildProps {
395 style: Option<String>,
396 ref_: NodeRef,
397 children: Children,
398}
399
400#[function_component(SplitPanelChild)]
401fn split_panel_child(props: &SplitPanelChildProps) -> Html {
402 let class = if props.style.is_some() {
403 classes!("split-panel-child", "is-width-override")
404 } else {
405 classes!("split-panel-child")
406 };
407 html! {
408 <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
409 { props.children.iter().next().unwrap() }
410 </div>
411 }
412}
413
414struct ResizingState {
417 mousemove: Closure<dyn Fn(MouseEvent)>,
418 mouseup: Closure<dyn Fn(MouseEvent)>,
419 cursor: String,
420 index: usize,
421 start: i32,
422 total: i32,
423 alt: i32,
424 orientation: Orientation,
425 reverse: bool,
426 body_style: web_sys::CssStyleDeclaration,
427 pointer_id: i32,
428 pointer_elem: HtmlElement,
429}
430
431impl Drop for ResizingState {
432 fn drop(&mut self) {
436 let result: ApiResult<()> = (|| {
437 let mousemove = self.mousemove.as_ref().unchecked_ref();
438 global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
439 let mouseup = self.mouseup.as_ref().unchecked_ref();
440 global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
441 self.release_cursor()?;
442 Ok(())
443 })();
444
445 result.expect("Drop failed")
446 }
447}
448
449const MINIMUM_SIZE: i32 = 8;
452
453impl ResizingState {
456 pub fn new(
457 index: usize,
458 client_offset: i32,
459 ctx: &Context<SplitPanel>,
460 first_elem: &HtmlElement,
461 pointer_id: i32,
462 pointer_elem: HtmlElement,
463 ) -> ApiResult<Self> {
464 let orientation = ctx.props().orientation;
465 let reverse = ctx.props().reverse;
466 let split_panel = ctx.link();
467 let total = match orientation {
468 Orientation::Horizontal => first_elem.offset_width(),
469 Orientation::Vertical => first_elem.offset_height(),
470 };
471
472 let alt = match orientation {
473 Orientation::Horizontal => first_elem.offset_height(),
474 Orientation::Vertical => first_elem.offset_width(),
475 };
476
477 let mouseup = Closure::new({
478 let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
479 move |x| cb.emit(x)
480 });
481
482 let mousemove = Closure::new({
483 let cb = split_panel.callback(move |event: MouseEvent| {
484 SplitPanelMsg::MoveResizing(match orientation {
485 Orientation::Horizontal => event.client_x(),
486 Orientation::Vertical => event.client_y(),
487 })
488 });
489
490 move |x| cb.emit(x)
491 });
492
493 let mut state = Self {
494 index,
495 cursor: "".to_owned(),
496 start: client_offset,
497 orientation,
498 reverse,
499 total,
500 alt,
501 body_style: global::body().style(),
502 mouseup,
503 mousemove,
504 pointer_id,
505 pointer_elem,
506 };
507
508 state.capture_cursor()?;
509 state.register_listeners()?;
510 Ok(state)
511 }
512
513 fn get_offset(&self, client_offset: i32) -> i32 {
514 let delta = if self.reverse {
515 self.start - client_offset
516 } else {
517 client_offset - self.start
518 };
519
520 max(MINIMUM_SIZE, self.total + delta)
521 }
522
523 pub fn get_style(&self, client_offset: i32) -> Option<String> {
524 let offset = self.get_offset(client_offset);
525 Some(match self.orientation {
526 Orientation::Horizontal => {
527 format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
528 },
529 Orientation::Vertical => {
530 format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
531 },
532 })
533 }
534
535 pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
536 let offset = self.get_offset(client_offset);
537 match self.orientation {
538 Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
539 Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
540 }
541 }
542
543 fn register_listeners(&self) -> ApiResult<()> {
545 let mousemove = self.mousemove.as_ref().unchecked_ref();
546 global::body().add_event_listener_with_callback("mousemove", mousemove)?;
547 let mouseup = self.mouseup.as_ref().unchecked_ref();
548 Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
549 }
550
551 fn capture_cursor(&mut self) -> ApiResult<()> {
554 self.pointer_elem.set_pointer_capture(self.pointer_id)?;
555 self.cursor = self.body_style.get_property_value("cursor")?;
556 self.body_style
557 .set_property("cursor", match self.orientation {
558 Orientation::Horizontal => "col-resize",
559 Orientation::Vertical => "row-resize",
560 })?;
561
562 Ok(())
563 }
564
565 fn release_cursor(&self) -> ApiResult<()> {
567 self.pointer_elem.release_pointer_capture(self.pointer_id)?;
568 Ok(self.body_style.set_property("cursor", &self.cursor)?)
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn html_if_empty_branch_is_not_literally_empty() {
578 let via_if: Html = html! { if false { <div /> } else { <></> } };
579 assert_ne!(via_if, html! { <></> });
580 }
581
582 #[test]
583 fn empty_fragment_is_empty() {
584 assert!(is_empty_html(&html! { <></> }));
585 }
586
587 #[test]
588 fn html_if_empty_branch_is_semantically_empty() {
589 assert!(is_empty_html(&html! { if false { <div/> } else { <></> } }));
590 }
591
592 #[test]
593 fn nested_empty_fragments_are_empty() {
594 assert!(is_empty_html(&html! { <><><></></></> }));
595 }
596
597 #[test]
598 fn tag_is_not_empty() {
599 assert!(!is_empty_html(&html! { <div/> }));
600 }
601
602 #[test]
603 fn fragment_containing_a_tag_is_not_empty() {
604 assert!(!is_empty_html(&html! { <><div/></> }));
605 }
606
607 #[test]
608 fn text_is_not_empty() {
609 assert!(!is_empty_html(&html! { { "" } }));
610 }
611}