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]
35 pub skip_empty: bool,
36
37 #[prop_or_default]
39 pub no_wrap: bool,
40
41 #[prop_or_default]
43 pub reverse: bool,
44
45 #[prop_or_default]
46 pub on_reset: Option<Callback<()>>,
47
48 #[prop_or_default]
49 pub on_resize: Option<Callback<(i32, i32)>>,
50
51 #[prop_or_default]
52 pub on_resize_finished: Option<Callback<()>>,
53
54 #[prop_or_default]
55 pub initial_size: Option<i32>,
56}
57
58impl SplitPanelProps {
59 fn validate(&self) -> bool {
60 !self.children.is_empty()
61 }
62}
63
64impl PartialEq for SplitPanelProps {
65 fn eq(&self, other: &Self) -> bool {
66 self.id == other.id
67 && self.children == other.children
68 && self.orientation == other.orientation
69 && self.reverse == other.reverse
70 }
71}
72
73pub enum SplitPanelMsg {
74 StartResizing(usize, i32, i32, HtmlElement),
75 MoveResizing(i32),
76 StopResizing,
77 Reset(usize),
78}
79
80pub struct SplitPanel {
96 resize_state: Option<ResizingState>,
97 refs: Vec<NodeRef>,
98 styles: Vec<Option<String>>,
99 on_reset: Option<Callback<()>>,
100}
101
102impl Component for SplitPanel {
103 type Message = SplitPanelMsg;
104 type Properties = SplitPanelProps;
105
106 fn create(ctx: &Context<Self>) -> Self {
107 assert!(ctx.props().validate());
108 let len = ctx.props().children.len();
109 let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
112
113 let mut styles = vec![Default::default(); len];
114 if let Some(x) = &ctx.props().initial_size {
115 styles[0] = Some(match ctx.props().orientation {
116 Orientation::Horizontal => {
117 format!("max-width:{x}px;min-width:{x}px;width:{x}px")
118 },
119 Orientation::Vertical => {
120 format!("max-height:{x}px;min-height:{x}px;height:{x}px")
121 },
122 });
123 }
124
125 Self {
126 resize_state: None,
127 refs,
128 styles,
129 on_reset: None,
130 }
131 }
132
133 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
134 match msg {
135 SplitPanelMsg::Reset(index) => {
136 self.styles[index] = None;
137 self.on_reset.clone_from(&ctx.props().on_reset);
138 },
139 SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
140 let elem = self.refs[index].cast::<HtmlElement>().unwrap();
141 let state =
142 ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
143
144 self.resize_state = state.ok();
145 },
146 SplitPanelMsg::StopResizing => {
147 self.resize_state = None;
148 if let Some(cb) = &ctx.props().on_resize_finished {
149 cb.emit(());
150 }
151 },
152 SplitPanelMsg::MoveResizing(client_offset) => {
153 if let Some(state) = self.resize_state.as_ref() {
154 if let Some(ref cb) = ctx.props().on_resize {
155 cb.emit(state.get_dimensions(client_offset));
156 }
157
158 self.styles[state.index] = state.get_style(client_offset);
159 }
160 },
161 };
162 true
163 }
164
165 fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
166 if let Some(on_reset) = self.on_reset.take() {
167 on_reset.emit(());
168 }
169 }
170
171 fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
172 assert!(ctx.props().validate());
173 let new_len = ctx.props().children.len();
174 self.refs.resize_with(new_len, Default::default);
175 self.styles.resize(new_len, Default::default());
176 true
177 }
178
179 fn view(&self, ctx: &Context<Self>) -> Html {
180 let iter = ctx
181 .props()
182 .children
183 .iter()
184 .filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
185 .enumerate()
186 .collect::<Vec<_>>();
187
188 let orientation = ctx.props().orientation;
189 let mut classes = classes!("split-panel");
190 if orientation == Orientation::Vertical {
191 classes.push("orient-vertical");
192 }
193
194 if ctx.props().reverse {
195 classes.push("orient-reverse");
196 }
197
198 let count = iter.len();
199 let contents = html! {
200 <>
201 for (i, x) in iter {
202 if i == 0 {
203 if count == 1 {
204 <key=0>
205 {x}
206 </>
207 } else {
208 <SplitPanelChild
209 key=0
210 style={self.styles[i].clone()}
211 ref_={self.refs[i].clone()}
212 >
213 { x }
214 </SplitPanelChild>
215 }
216 } else if i == count - 1 {
217 <key={i}>
218 <SplitPanelDivider
219 i={i - 1}
220 orientation={ctx.props().orientation}
221 link={ctx.link().clone()}
222 />
223 { x }
224 </>
225 } else {
226 <key={i}>
227 <SplitPanelDivider
228 i={i - 1}
229 orientation={ctx.props().orientation}
230 link={ctx.link().clone()}
231 />
232 <SplitPanelChild
233 style={self.styles[i].clone()}
234 ref_={self.refs[i].clone()}
235 >
236 { x }
237 </SplitPanelChild>
238 </>
239 }
240 }
241 </>
242 };
243
244 if ctx.props().no_wrap {
246 html! { { contents } }
247 } else {
248 html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
249 }
250 }
251}
252
253#[derive(Clone, Copy, Default, Eq, PartialEq)]
254pub enum Orientation {
255 #[default]
256 Horizontal,
257 Vertical,
258}
259
260#[derive(Properties)]
261struct SplitPanelDividerProps {
262 i: usize,
263 orientation: Orientation,
264 link: Scope<SplitPanel>,
265}
266
267impl PartialEq for SplitPanelDividerProps {
268 fn eq(&self, rhs: &Self) -> bool {
269 self.i == rhs.i && self.orientation == rhs.orientation
270 }
271}
272
273#[function_component(SplitPanelDivider)]
275fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
276 let orientation = props.orientation;
277 let i = props.i;
278 let link = props.link.clone();
279 let onmousedown = link.callback(move |event: PointerEvent| {
280 let target = event.target().unwrap().unchecked_into::<HtmlElement>();
281 let pointer_id = event.pointer_id();
282 let size = match orientation {
283 Orientation::Horizontal => event.client_x(),
284 Orientation::Vertical => event.client_y(),
285 };
286
287 SplitPanelMsg::StartResizing(i, size, pointer_id, target)
288 });
289
290 let ondblclick = props.link.callback(move |event: MouseEvent| {
291 event.prevent_default();
292 event.stop_propagation();
293 SplitPanelMsg::Reset(i)
294 });
295
296 let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
302
303 html! {
304 <>
305 <div
306 class="split-panel-divider"
307 {ondragstart}
308 onpointerdown={onmousedown}
309 {ondblclick}
310 />
311 </>
312 }
313}
314
315#[derive(Properties, PartialEq)]
316struct SplitPanelChildProps {
317 style: Option<String>,
318 ref_: NodeRef,
319 children: Children,
320}
321
322#[function_component(SplitPanelChild)]
323fn split_panel_child(props: &SplitPanelChildProps) -> Html {
324 let class = if props.style.is_some() {
325 classes!("split-panel-child", "is-width-override")
326 } else {
327 classes!("split-panel-child")
328 };
329 html! {
330 <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
331 { props.children.iter().next().unwrap() }
332 </div>
333 }
334}
335
336struct ResizingState {
339 mousemove: Closure<dyn Fn(MouseEvent)>,
340 mouseup: Closure<dyn Fn(MouseEvent)>,
341 cursor: String,
342 index: usize,
343 start: i32,
344 total: i32,
345 alt: i32,
346 orientation: Orientation,
347 reverse: bool,
348 body_style: web_sys::CssStyleDeclaration,
349 pointer_id: i32,
350 pointer_elem: HtmlElement,
351}
352
353impl Drop for ResizingState {
354 fn drop(&mut self) {
358 let result: ApiResult<()> = (|| {
359 let mousemove = self.mousemove.as_ref().unchecked_ref();
360 global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
361 let mouseup = self.mouseup.as_ref().unchecked_ref();
362 global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
363 self.release_cursor()?;
364 Ok(())
365 })();
366
367 result.expect("Drop failed")
368 }
369}
370
371const MINIMUM_SIZE: i32 = 8;
374
375impl ResizingState {
378 pub fn new(
379 index: usize,
380 client_offset: i32,
381 ctx: &Context<SplitPanel>,
382 first_elem: &HtmlElement,
383 pointer_id: i32,
384 pointer_elem: HtmlElement,
385 ) -> ApiResult<Self> {
386 let orientation = ctx.props().orientation;
387 let reverse = ctx.props().reverse;
388 let split_panel = ctx.link();
389 let total = match orientation {
390 Orientation::Horizontal => first_elem.offset_width(),
391 Orientation::Vertical => first_elem.offset_height(),
392 };
393
394 let alt = match orientation {
395 Orientation::Horizontal => first_elem.offset_height(),
396 Orientation::Vertical => first_elem.offset_width(),
397 };
398
399 let mouseup = Closure::new({
400 let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
401 move |x| cb.emit(x)
402 });
403
404 let mousemove = Closure::new({
405 let cb = split_panel.callback(move |event: MouseEvent| {
406 SplitPanelMsg::MoveResizing(match orientation {
407 Orientation::Horizontal => event.client_x(),
408 Orientation::Vertical => event.client_y(),
409 })
410 });
411
412 move |x| cb.emit(x)
413 });
414
415 let mut state = Self {
416 index,
417 cursor: "".to_owned(),
418 start: client_offset,
419 orientation,
420 reverse,
421 total,
422 alt,
423 body_style: global::body().style(),
424 mouseup,
425 mousemove,
426 pointer_id,
427 pointer_elem,
428 };
429
430 state.capture_cursor()?;
431 state.register_listeners()?;
432 Ok(state)
433 }
434
435 fn get_offset(&self, client_offset: i32) -> i32 {
436 let delta = if self.reverse {
437 self.start - client_offset
438 } else {
439 client_offset - self.start
440 };
441
442 max(MINIMUM_SIZE, self.total + delta)
443 }
444
445 pub fn get_style(&self, client_offset: i32) -> Option<String> {
446 let offset = self.get_offset(client_offset);
447 Some(match self.orientation {
448 Orientation::Horizontal => {
449 format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
450 },
451 Orientation::Vertical => {
452 format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
453 },
454 })
455 }
456
457 pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
458 let offset = self.get_offset(client_offset);
459 match self.orientation {
460 Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
461 Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
462 }
463 }
464
465 fn register_listeners(&self) -> ApiResult<()> {
467 let mousemove = self.mousemove.as_ref().unchecked_ref();
468 global::body().add_event_listener_with_callback("mousemove", mousemove)?;
469 let mouseup = self.mouseup.as_ref().unchecked_ref();
470 Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
471 }
472
473 fn capture_cursor(&mut self) -> ApiResult<()> {
476 self.pointer_elem.set_pointer_capture(self.pointer_id)?;
477 self.cursor = self.body_style.get_property_value("cursor")?;
478 self.body_style
479 .set_property("cursor", match self.orientation {
480 Orientation::Horizontal => "col-resize",
481 Orientation::Vertical => "row-resize",
482 })?;
483
484 Ok(())
485 }
486
487 fn release_cursor(&self) -> ApiResult<()> {
489 self.pointer_elem.release_pointer_capture(self.pointer_id)?;
490 Ok(self.body_style.set_property("cursor", &self.cursor)?)
491 }
492}