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