leptos_resize/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use leptos::{ev, html::Div, prelude::*, text_prop::TextProp};
4
5/// Enum representing the direction of the split.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum SplitDirection {
8  /// Split the container vertically.
9  #[default]
10  Row,
11  /// Split the container horizontally.
12  Column,
13}
14
15/// A resizable split component. Children will be put into a grid with
16/// columns or rows, depending on the direction. The number of children
17/// determines the number of splits.
18///
19/// # Example:
20/// ```
21/// use leptos_resize::ResizableSplit;
22///
23/// #[component]
24/// fn MyComponent() -> impl IntoView {
25///   view! {
26///     <ResizableSplit>
27///       <div>"First"</div>
28///       <div>"Second"</div>
29///       <div>"Third"</div>
30///     </ResizableSplit>
31///   }
32/// }
33/// ```
34#[component]
35pub fn ResizableSplit(
36  /// The direction of the split.
37  #[prop(into, optional)]
38  direction: SplitDirection,
39  /// The class property for the underlying grid.
40  #[prop(into, optional)]
41  class: TextProp,
42  /// The class property for the resize handle.
43  #[prop(into, optional)]
44  handle_class: TextProp,
45  /// The percentages of the splits. Will update on resize.
46  #[prop(optional)]
47  percentages: Option<RwSignal<Vec<f64>>>,
48  children: ChildrenFragment,
49) -> impl IntoView {
50  let container = NodeRef::<Div>::new();
51  let (is_resizing, set_resizing) = signal(false);
52  let (resizing_index, set_resizing_index) = signal(None::<usize>);
53
54  // Get the number of children for later calculations.
55  let children = children().nodes.into_iter().collect::<Vec<_>>();
56  let num_children = children.len();
57
58  // If no `col` signal is provided, create a default one.
59  let percentages = percentages.unwrap_or_else(|| {
60    let initial_size = 100.0 / num_children as f64;
61    RwSignal::new(vec![initial_size; num_children - 1])
62  });
63
64  let on_mouse_down = move |index: usize| {
65    move |ev: ev::MouseEvent| {
66      ev.prevent_default();
67      set_resizing.set(true);
68      set_resizing_index.set(Some(index));
69    }
70  };
71
72  let on_mouse_move = move |ev: ev::MouseEvent| {
73    ev.prevent_default();
74
75    if !is_resizing.get() {
76      return;
77    }
78
79    if let (Some(index), Some(container)) =
80      (resizing_index.get(), container.get())
81    {
82      let bounds = container.get_bounding_client_rect();
83
84      let new_percentages = match direction {
85        SplitDirection::Row => {
86          let x = ev.client_x() as f64 - bounds.left();
87          let x_percentage = (x / bounds.width()) * 100.0;
88          calculate_new_percentages(percentages.get(), index, x_percentage)
89        }
90        SplitDirection::Column => {
91          let y = ev.client_y() as f64 - bounds.top();
92          let y_percentage = (y / bounds.height()) * 100.0;
93          calculate_new_percentages(percentages.get(), index, y_percentage)
94        }
95      };
96      percentages.set(new_percentages);
97    }
98  };
99
100  let stop_resize = move |ev: ev::MouseEvent| {
101    ev.prevent_default();
102    set_resizing.set(false);
103    set_resizing_index.set(None);
104  };
105
106  let grid_template_columns = move || match direction {
107    SplitDirection::Row => {
108      let mut grid_template = String::new();
109      for (i, percentage) in percentages.get().iter().enumerate() {
110        grid_template.push_str(&format!("{:.2}% ", percentage));
111        if i < percentages.get().len() - 1 {
112          grid_template.push(' ');
113        }
114      }
115      grid_template.push_str(&format!(
116        "{:.2}%",
117        100.0 - percentages.get().iter().sum::<f64>()
118      ));
119      grid_template
120    }
121    SplitDirection::Column => "100%".into(),
122  };
123
124  let grid_template_rows = move || match direction {
125    SplitDirection::Row => "100%".into(),
126    SplitDirection::Column => {
127      let mut grid_template = String::new();
128      for (i, percentage) in percentages.get().iter().enumerate() {
129        grid_template.push_str(&format!("{:.2}% ", percentage));
130        if i < percentages.get().len() - 1 {
131          grid_template.push(' ');
132        }
133      }
134      grid_template.push_str(&format!(
135        "{:.2}%",
136        100.0 - percentages.get().iter().sum::<f64>()
137      ));
138      grid_template
139    }
140  };
141
142  let handle_style = move |index: usize| {
143    let position = percentages
144      .get()
145      .iter()
146      .take(index + 1)
147      .fold(0.0, |acc, &x| acc + x);
148
149    let styles = match direction {
150      SplitDirection::Row => {
151        format!(
152          "cursor: col-resize; width: 20px; transform: translateX(-50%, 0%); \
153           top: 0; bottom: 0; left: calc({:.2}% - 10px)",
154          position
155        )
156      }
157      SplitDirection::Column => {
158        format!(
159          "cursor: row-resize; height: 20px; transform: translateY(-50%, 0%); \
160           top: calc({:.2}% - 10px); left: 0; right: 0",
161          position
162        )
163      }
164    };
165    format!("position: absolute; z-index: 1; {}", styles)
166  };
167
168  view! {
169    <div
170      node_ref=container
171      class=move || class.get()
172      style:position="relative"
173      on:mousemove=on_mouse_move
174      on:mouseleave=stop_resize
175    >
176      {(0..num_children - 1)
177        .map({
178          let handle_class = handle_class.clone();
179          move |index| {
180            view! {
181              <div
182                class={
183                  let handle_class = handle_class.clone();
184                  move || handle_class.get()
185                }
186                style=move || handle_style(index)
187                on:mousedown=on_mouse_down(index)
188                on:mouseup=stop_resize
189              />
190            }
191          }
192        })
193        .collect_view()}
194      <div
195        style:display="grid"
196        style:grid-template-columns=grid_template_columns
197        style:grid-template-rows=grid_template_rows
198        style:width="100%"
199        style:height="100%"
200      >
201        {children}
202      </div>
203    </div>
204  }
205}
206
207/// Calculates the new percentages for the splits based on the current mouse
208/// position.
209fn calculate_new_percentages(
210  current_percentages: Vec<f64>,
211  index: usize,
212  new_percentage: f64,
213) -> Vec<f64> {
214  let mut new_percentages = current_percentages.clone();
215  if index < new_percentages.len() {
216    let prev_sum: f64 = current_percentages.iter().take(index).sum();
217    let next_sum: f64 = current_percentages.iter().skip(index + 1).sum();
218    let current_percentage = new_percentages[index];
219
220    // Check if the new percentage is within the allowed bounds
221    if new_percentage >= prev_sum && new_percentage <= 100.0 - next_sum {
222      new_percentages[index] = new_percentage - prev_sum;
223      if index < new_percentages.len() - 1 {
224        new_percentages[index + 1] +=
225          current_percentage - new_percentages[index];
226      }
227    }
228  }
229  new_percentages
230}