Skip to main content

hadrone_leptos/
lib.rs

1use hadrone_core::interaction::{InteractionSession, InteractionType};
2use hadrone_core::{
3    resize_handle_aria_label, CollisionStrategy, CompactionType, Compactor, FreePlacementCompactor,
4    LayoutEngine, LayoutItem, ResizeHandle, RisingTideCompactor,
5};
6
7pub use hadrone_core::{InteractionPhase, LayoutEvent};
8use leptos::ev;
9use leptos::ev::PointerEvent;
10use leptos::*;
11
12fn leptos_apply_keyboard_cell_nudge(
13    layout: RwSignal<Vec<LayoutItem>>,
14    cols: i32,
15    compaction: CompactionType,
16    item_id: &str,
17    dx: i32,
18    dy: i32,
19) {
20    layout.update(|l| {
21        let Some((nx, ny)) = l
22            .iter()
23            .find(|i| i.id == item_id)
24            .filter(|it| it.can_drag())
25            .map(|it| (it.x + dx, it.y + dy))
26        else {
27            return;
28        };
29        let compactor: Box<dyn Compactor> = match compaction {
30            CompactionType::Gravity => Box::new(RisingTideCompactor),
31            CompactionType::FreePlacement => Box::new(FreePlacementCompactor),
32        };
33        let engine = LayoutEngine::with_default_collision(compactor, cols);
34        engine.move_element(l, item_id, nx, ny);
35    });
36}
37
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct GridConfig {
40    pub cols: i32,
41    pub row_height: f32,
42    pub margin: (i32, i32),
43}
44
45#[component]
46pub fn GridLayout(
47    layout: RwSignal<Vec<LayoutItem>>,
48    #[prop(into)] cols: Signal<i32>,
49    #[prop(into)] row_height: Signal<f32>,
50    #[prop(into)] margin: Signal<(i32, i32)>,
51    #[prop(into)] compaction: Signal<CompactionType>,
52    #[prop(default = false)] keyboard_cell_nudge: bool,
53    render_item: fn(LayoutItem) -> View,
54) -> impl IntoView {
55    let active = create_rw_signal(None::<InteractionSession>);
56    let visual_delta = create_rw_signal(None::<(f32, f32, f32, f32)>);
57    let container_width = create_rw_signal(1200.0f32);
58    let container_ref = create_node_ref::<html::Div>();
59
60    // Track container width
61    create_effect(move |_| {
62        if let Some(el) = container_ref.get() {
63            let el_clone = el.clone();
64            let handle = window_event_listener(ev::resize, move |_| {
65                container_width.set(el_clone.client_width() as f32);
66            });
67            on_cleanup(move || drop(handle));
68            container_width.set(el.client_width() as f32);
69        }
70    });
71
72    let total_height = create_memo(move |prev: Option<&f32>| {
73        if active.get().is_some() {
74            return *prev.unwrap_or(&500.0);
75        }
76        let l = layout.get();
77        let max_y = l.iter().map(|item| item.y + item.h).max().unwrap_or(0);
78        let rh = row_height.get();
79        let my = margin.get().1;
80        (max_y as f32 * (rh + my as f32)).max(500.0)
81    });
82
83    // Auto-compaction when cols/compaction changes
84    create_effect(move |_| {
85        if active.get().is_some() {
86            return;
87        }
88        let mut current_layout = layout.get_untracked();
89        let ccols = cols.get();
90        let compactor: Box<dyn Compactor> = match compaction.get() {
91            CompactionType::Gravity => Box::new(RisingTideCompactor),
92            CompactionType::FreePlacement => Box::new(FreePlacementCompactor),
93        };
94        let engine = LayoutEngine::with_default_collision(compactor, ccols);
95        for item in current_layout.iter_mut() {
96            if !item.is_static {
97                item.w = item.w.min(ccols);
98                item.x = item.x.max(0).min(ccols - item.w);
99            }
100        }
101        engine.compact(&mut current_layout);
102        layout.set(current_layout);
103    });
104
105    let on_pointer_move = move |e: PointerEvent| {
106        if let Some(interaction) = active.get().as_ref() {
107            let coords = (e.client_x() as f32, e.client_y() as f32);
108            visual_delta.set(Some(interaction.get_visual_delta(coords)));
109            let mut new_layout = layout.get_untracked();
110            interaction.update(coords, &mut new_layout, cols.get());
111            layout.set(new_layout);
112        }
113    };
114
115    let on_resize_up = move |e: PointerEvent| {
116        if active.get().is_some() {
117            handle_capture_release(e.pointer_id());
118            active.set(None);
119            visual_delta.set(None);
120        }
121    };
122
123    let style = move || {
124        format!(
125        "position: relative; width: 100%; height: {}px; contain: layout; touch-action: none; user-select: none;",
126        total_height.get()
127    )
128    };
129
130    let pointer_interaction = Signal::derive(move || active.get().is_some());
131
132    view! {
133        <div
134            node_ref=container_ref
135            class="hadrone-container"
136            style=style
137            data-active=move || active.get().is_some().to_string()
138            role="application"
139            aria-label="Draggable grid layout. Use Tab to reach widgets and resize handles. Arrow keys move the focused widget when keyboard nudge is enabled."
140            on:pointermove=on_pointer_move
141            on:pointerup=on_resize_up
142            on:pointerleave=on_resize_up
143            on:pointercancel=on_resize_up
144        >
145            <style>
146                "
147                .resize-handle { opacity: 0; pointer-events: none; transition: opacity 0.15s ease-in-out; }
148                .grid-item:hover .resize-handle { opacity: 1; pointer-events: auto; }
149                .hadrone-container[data-active=\"true\"] { cursor: grabbing !important; }
150                .hadrone-container[data-active=\"true\"] .grid-item:not([data-active=\"true\"]) .resize-handle { opacity: 0 !important; pointer-events: none !important; }
151                .grid-item[data-active=\"true\"] .resize-handle { opacity: 1 !important; pointer-events: auto !important; }
152                .grid-item-inner:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
153                .resize-handle:focus-visible { opacity: 1 !important; pointer-events: auto !important; outline: 2px solid #2563eb; outline-offset: 2px; }
154                "
155            </style>
156            <For
157                each=move || layout.get()
158                key=|item| item.id.clone()
159                children=move |item| {
160                    let item_id = item.id.clone();
161                    let item_id_for_active   = item_id.clone();
162                    let item_id_for_rect     = item_id.clone();
163                    let item_id_for_delta    = item_id.clone();
164                    let item_id_for_drag     = item_id.clone();
165                    let item_id_for_resize   = item_id.clone();
166
167                    let is_active_sig = Signal::derive(move || {
168                        active.get().as_ref().is_some_and(|a| a.id == item_id_for_active)
169                    });
170
171                    let start_rect_sig = Signal::derive(move || {
172                        if active.get().as_ref().is_some_and(|a| a.id == item_id_for_rect) {
173                            active.get().as_ref().map(|a| a.start_rect)
174                        } else {
175                            None
176                        }
177                    });
178
179                    let visual_delta_sig = Signal::derive(move || {
180                        if active.get().as_ref().is_some_and(|a| a.id == item_id_for_delta) {
181                            visual_delta.get()
182                        } else {
183                            None
184                        }
185                    });
186
187                    let on_drag_start = move |e: PointerEvent| {
188                        handle_capture_set(e.pointer_id());
189                        let start_mouse = (e.client_x() as f32, e.client_y() as f32);
190                        let Some(i) = layout
191                            .get_untracked()
192                            .into_iter()
193                            .find(|it| it.id == item_id_for_drag)
194                        else {
195                            return;
196                        };
197                        if !i.can_drag() {
198                            return;
199                        }
200                        let session = InteractionSession {
201                            id: i.id.clone(),
202                            start_mouse,
203                            start_rect: (i.x, i.y, i.w, i.h),
204                            interaction_type: InteractionType::Drag,
205                            handle: ResizeHandle::SouthEast,
206                            col_width_px: container_width.get() / cols.get() as f32,
207                            row_height_px: row_height.get(),
208                            margin: margin.get(),
209                            container_padding: (0, 0),
210                            compaction: compaction.get(),
211                            collision: CollisionStrategy::PushDown,
212                        };
213                        visual_delta.set(Some(session.get_visual_delta(start_mouse)));
214                        active.set(Some(session));
215                    };
216
217                    let handles: Vec<ResizeHandle> = item
218                        .resize_handles
219                        .iter()
220                        .cloned()
221                        .filter(|h| {
222                            item.can_resize()
223                                && matches!(
224                                    h,
225                                    ResizeHandle::SouthEast | ResizeHandle::South | ResizeHandle::East
226                                )
227                        })
228                        .collect();
229
230                    let on_resize_start = move |e: PointerEvent, handle: ResizeHandle| {
231                        handle_capture_set(e.pointer_id());
232                        let start_mouse = (e.client_x() as f32, e.client_y() as f32);
233                        let Some(i) = layout
234                            .get_untracked()
235                            .into_iter()
236                            .find(|it| it.id == item_id_for_resize)
237                        else {
238                            return;
239                        };
240                        if !i.can_resize() {
241                            return;
242                        }
243                        let session = InteractionSession {
244                            id: i.id.clone(),
245                            start_mouse,
246                            start_rect: (i.x, i.y, i.w, i.h),
247                            interaction_type: InteractionType::Resize,
248                            handle,
249                            col_width_px: container_width.get() / cols.get() as f32,
250                            row_height_px: row_height.get(),
251                            margin: margin.get(),
252                            container_padding: (0, 0),
253                            compaction: compaction.get(),
254                            collision: CollisionStrategy::PushDown,
255                        };
256                        visual_delta.set(Some(session.get_visual_delta(start_mouse)));
257                        active.set(Some(session));
258                    };
259
260                    view! {
261                        <GridItem
262                            item=item.clone()
263                            layout=layout
264                            cols=cols
265                            row_height=row_height
266                            margin=margin
267                            compaction=compaction
268                            keyboard_cell_nudge=keyboard_cell_nudge
269                            pointer_interaction=pointer_interaction
270                            is_active=is_active_sig
271                            start_rect=start_rect_sig
272                            visual_delta=visual_delta_sig
273                            render_item=render_item
274                            on_drag_start=on_drag_start
275                            on_resize_start=on_resize_start
276                            resize_handles=handles
277                        />
278                    }
279                }
280            />
281        </div>
282    }
283}
284
285fn handle_capture_set(_pid: i32) {
286    #[cfg(target_arch = "wasm32")]
287    {
288        if let Some(el) = web_sys::window()
289            .and_then(|w| w.document())
290            .and_then(|d| d.query_selector(".hadrone-container").ok().flatten())
291        {
292            let _ = el.set_pointer_capture(_pid);
293        }
294    }
295}
296
297fn handle_capture_release(_pid: i32) {
298    #[cfg(target_arch = "wasm32")]
299    {
300        if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| {
301            d.query_selector(".hadrone-container[data-active='true']")
302                .ok()
303                .flatten()
304        }) {
305            let _ = el.release_pointer_capture(_pid);
306        }
307    }
308}
309
310#[component]
311pub fn GridItem<F, R>(
312    item: LayoutItem,
313    layout: RwSignal<Vec<LayoutItem>>,
314    #[prop(into)] cols: Signal<i32>,
315    #[prop(into)] row_height: Signal<f32>,
316    #[prop(into)] margin: Signal<(i32, i32)>,
317    #[prop(into)] compaction: Signal<CompactionType>,
318    #[prop(into)] pointer_interaction: Signal<bool>,
319    keyboard_cell_nudge: bool,
320    #[prop(into)] is_active: Signal<bool>,
321    #[prop(into)] start_rect: Signal<Option<(i32, i32, i32, i32)>>,
322    #[prop(into)] visual_delta: Signal<Option<(f32, f32, f32, f32)>>,
323    render_item: fn(LayoutItem) -> View,
324    on_drag_start: F,
325    on_resize_start: R,
326    resize_handles: Vec<ResizeHandle>,
327) -> impl IntoView
328where
329    F: Fn(PointerEvent) + 'static,
330    R: Fn(PointerEvent, ResizeHandle) + Clone + 'static,
331{
332    let col_width_pct = move || 100.0 / cols.get() as f32;
333
334    let style = move || {
335        let (left_str, top_str, width_str, height_str) =
336            if let (Some((dx, dy, dw, dh)), Some(sr)) = (visual_delta.get(), start_rect.get()) {
337                let start_left_pct = sr.0 as f32 * col_width_pct();
338                let start_top_px = sr.1 as f32 * (row_height.get() + margin.get().1 as f32);
339                let start_width_pct = sr.2 as f32 * col_width_pct();
340                let start_height_px =
341                    sr.3 as f32 * row_height.get() + (sr.3 as f32 - 1.0) * margin.get().1 as f32;
342                (
343                    format!("calc({}% + {}px)", start_left_pct, dx),
344                    format!("{}px", start_top_px + dy),
345                    format!(
346                        "calc({}% - {}px + {}px)",
347                        start_width_pct,
348                        margin.get().0,
349                        dw
350                    ),
351                    format!("{}px", start_height_px + dh),
352                )
353            } else {
354                (
355                    format!("{}%", item.x as f32 * col_width_pct()),
356                    format!(
357                        "{}px",
358                        item.y as f32 * (row_height.get() + margin.get().1 as f32)
359                    ),
360                    format!(
361                        "calc({}% - {}px)",
362                        item.w as f32 * col_width_pct(),
363                        margin.get().0
364                    ),
365                    format!(
366                        "{}px",
367                        item.h as f32 * row_height.get()
368                            + (item.h as f32 - 1.0) * margin.get().1 as f32
369                    ),
370                )
371            };
372
373        let transform = if is_active.get() {
374            "scale(1.025) translate3d(0,0,0)"
375        } else {
376            "scale(1) translate3d(0,0,0)"
377        };
378        let z = if is_active.get() { 100 } else { 0 };
379
380        format!(
381            "position: absolute; left: {}; top: {}; width: {}; height: {}; z-index: {}; pointer-events: auto; transform: {}; transition: transform 0.15s ease-out; touch-action: none; user-select: none;",
382            left_str, top_str, width_str, height_str, z, transform
383        )
384    };
385
386    let item_id_for_kb = item.id.clone();
387    let handles_view = resize_handles.into_iter().map(|handle| {
388        let on_resize_start = on_resize_start.clone();
389        let handle_style = resize_handle_style(handle);
390        let aria = resize_handle_aria_label(handle);
391        view! {
392            <div
393                class="resize-handle"
394                style=format!("position: absolute; touch-action: none; z-index: 20; {}", handle_style)
395                tabindex="0"
396                role="button"
397                aria-label=aria
398                on:pointerdown=move |e: PointerEvent| {
399                    e.stop_propagation();
400                    on_resize_start(e, handle);
401                }
402            >
403                {if handle == ResizeHandle::SouthEast {
404                    view! {
405                        <svg width="14" height="14" viewBox="0 0 12 12" style="opacity: 0.4; pointer-events: none;">
406                            <path d="M10 2 L10 10 L2 10 Z" fill="currentColor"/>
407                        </svg>
408                    }.into_view()
409                } else {
410                    view! { <div></div> }.into_view()
411                }}
412            </div>
413        }
414    }).collect::<Vec<_>>();
415
416    let aria_widget = format!("Widget {}, draggable grid item", item.id);
417
418    view! {
419        <div class="grid-item" style=style data-active=move || is_active.get().to_string()>
420            <div
421                class="grid-item-inner"
422                style="width: 100%; height: 100%; position: relative;"
423                tabindex="0"
424                role="group"
425                aria-label=aria_widget.clone()
426                aria-grabbed=move || if is_active.get() { "true" } else { "false" }
427                on:pointerdown=on_drag_start
428                on:keydown=move |ev: ev::KeyboardEvent| {
429                    if !keyboard_cell_nudge || pointer_interaction.get() {
430                        return;
431                    }
432                    let (dx, dy) = match ev.key().as_str() {
433                        "ArrowLeft" => (-1, 0),
434                        "ArrowRight" => (1, 0),
435                        "ArrowUp" => (0, -1),
436                        "ArrowDown" => (0, 1),
437                        _ => return,
438                    };
439                    ev.prevent_default();
440                    ev.stop_propagation();
441                    leptos_apply_keyboard_cell_nudge(
442                        layout,
443                        cols.get(),
444                        compaction.get(),
445                        &item_id_for_kb,
446                        dx,
447                        dy,
448                    );
449                }
450            >
451                {render_item(item.clone())}
452            </div>
453            {handles_view}
454        </div>
455    }
456}
457
458fn resize_handle_style(handle: ResizeHandle) -> String {
459    match handle {
460        ResizeHandle::SouthEast =>
461            "bottom: -8px; right: -8px; cursor: nwse-resize; width: 40px; height: 40px; display: flex; align-items: flex-end; justify-content: flex-end; padding: 12px;".into(),
462        ResizeHandle::South =>
463            "bottom: -8px; left: 30px; right: 30px; height: 16px; cursor: ns-resize; display: flex; justify-content: center; align-items: center;".into(),
464        ResizeHandle::East =>
465            "top: 30px; bottom: 30px; right: -8px; width: 16px; cursor: ew-resize; display: flex; align-items: center; justify-content: center;".into(),
466        _ => "".into(),
467    }
468}