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