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