Skip to main content

hadrone_yew/
lib.rs

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