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