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 {
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 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 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}