1use dioxus::prelude::keyboard_types::Key;
2use dioxus::prelude::*;
3use hadrone_core::interaction::{InteractionSession, InteractionType};
4use hadrone_core::{
5 CollisionStrategy, CompactionType, Compactor, FreePlacementCompactor, InteractionPhase,
6 LayoutEngine, LayoutEvent, LayoutItem, ResizeHandle, RisingTideCompactor,
7 resize_handle_aria_label,
8};
9use std::time::Duration;
10
11fn apply_keyboard_cell_nudge(
12 mut layout: Signal<Vec<LayoutItem>>,
13 cols: i32,
14 compaction: CompactionType,
15 item_id: &str,
16 dx: i32,
17 dy: i32,
18) {
19 let mut l = layout.peek().clone();
20 let Some((nx, ny)) = l
21 .iter()
22 .find(|i| i.id == item_id)
23 .filter(|it| it.can_drag())
24 .map(|it| (it.x + dx, it.y + dy))
25 else {
26 return;
27 };
28 let compactor: Box<dyn Compactor> = match compaction {
29 CompactionType::Gravity => Box::new(RisingTideCompactor),
30 CompactionType::FreePlacement => Box::new(FreePlacementCompactor),
31 };
32 let engine = LayoutEngine::with_default_collision(compactor, cols);
33 engine.move_element(&mut l, item_id, nx, ny);
34 layout.set(l);
35}
36
37#[cfg(target_arch = "wasm32")]
38fn wasm_attach_resize_width_observer(el: web_sys::Element, mut width: Signal<f32>) {
39 use wasm_bindgen::JsCast;
40 use wasm_bindgen::closure::Closure;
41
42 let initial = el.client_width() as f32;
43 if initial > 0.0 {
44 width.set(initial);
45 }
46
47 let el_measure = el.clone();
48 let closure = Closure::wrap(Box::new(
49 move |_entries: js_sys::Array, _obs: web_sys::ResizeObserver| {
50 let w = el_measure.client_width() as f32;
51 if w > 0.0 {
52 width.set(w);
53 }
54 },
55 )
56 as Box<dyn FnMut(js_sys::Array, web_sys::ResizeObserver)>);
57
58 if let Ok(obs) = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref()) {
59 obs.observe(&el);
60 }
61 closure.forget();
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct GridConfig {
67 pub cols: i32,
69 pub row_height: f32,
71 pub margin: (i32, i32),
73 pub container_padding: (i32, i32),
75}
76
77#[derive(Props, Clone, PartialEq)]
79#[allow(unpredictable_function_pointer_comparisons)]
80pub struct GridLayoutProps {
81 pub layout: Signal<Vec<LayoutItem>>,
83 pub cols: i32,
86 pub row_height: f32,
88 pub margin: (i32, i32),
90 pub compaction: CompactionType,
92 pub render_item: fn(LayoutItem) -> Element,
94 pub on_layout_change: Option<EventHandler<Vec<LayoutItem>>>,
96 #[props(default = (0, 0))]
98 pub container_padding: (i32, i32),
99 #[props(default = CollisionStrategy::PushDown)]
101 pub collision_strategy: CollisionStrategy,
102 pub on_layout_event: Option<EventHandler<LayoutEvent>>,
104 #[props(default = false)]
106 pub emit_interaction_updates: bool,
107 #[props(default = false)]
109 pub keyboard_cell_nudge: bool,
110}
111
112#[component]
117pub fn GridLayout(props: GridLayoutProps) -> Element {
118 let mut layout = props.layout;
119 let compaction = props.compaction;
120 let collision_strategy = props.collision_strategy;
121 let emit_interaction_updates = props.emit_interaction_updates;
122 let on_layout_event = props.on_layout_event;
123 let container_pad = props.container_padding;
124 let config = GridConfig {
125 cols: props.cols,
126 row_height: props.row_height,
127 margin: props.margin,
128 container_padding: container_pad,
129 };
130
131 let mut active = use_signal(|| None::<InteractionSession>);
132 let mut visual_delta = use_signal(|| None::<(f32, f32, f32, f32)>);
133 let container_width = use_signal(|| 1200.0);
134
135 let is_active = active.read().is_some();
137
138 #[cfg(not(target_arch = "wasm32"))]
140 use_effect(move || {
141 let mut width = container_width;
142 spawn(async move {
143 loop {
144 if let Ok(eval) =
145 document::eval("document.querySelector('.hadrone-container')?.clientWidth")
146 .await
147 && let Some(w) = eval.as_f64()
148 {
149 width.set(w as f32);
150 }
151 tokio::time::sleep(Duration::from_millis(500)).await;
152 }
153 });
154 });
155
156 let total_height = use_memo(move || {
157 let max_y = layout
158 .read()
159 .iter()
160 .map(|item| item.y + item.h)
161 .max()
162 .unwrap_or(0);
163 (max_y as f32 * (props.row_height + props.margin.1 as f32)).max(500.0)
164 });
165 let container_style = format!(
166 "position: relative; width: 100%; height: {h}px; contain: layout; touch-action: none; user-select: none; \
167 box-sizing: border-box; padding-left: {pad_x}px; padding-top: {pad_y}px; \
168 --grid-cols: {cols}; --row-height: {row_height}px; --margin-x: {mx}px; --margin-y: {my}px;",
169 h = total_height(),
170 pad_x = container_pad.0,
171 pad_y = container_pad.1,
172 cols = props.cols,
173 row_height = props.row_height,
174 mx = props.margin.0,
175 my = props.margin.1
176 );
177
178 use_effect(move || {
180 if active.peek().is_some() {
182 return;
183 }
184
185 let mut current_layout = layout.peek().clone();
186 let compactor: Box<dyn Compactor> = match compaction {
187 CompactionType::Gravity => Box::new(RisingTideCompactor),
188 CompactionType::FreePlacement => Box::new(FreePlacementCompactor),
189 };
190 let engine = LayoutEngine::with_default_collision(compactor, props.cols);
191
192 for item in current_layout.iter_mut() {
194 if !item.is_static {
195 item.w = item.w.min(props.cols);
196 item.x = item.x.max(0).min(props.cols - item.w);
197 }
198 }
199
200 engine.compact(&mut current_layout);
201 layout.set(current_layout);
202 });
203
204 let current_layout = layout.read().clone();
206 let interaction_active = active.read().is_some();
207 let keyboard_cell_nudge = props.keyboard_cell_nudge;
208
209 rsx! {
210 div {
211 class: "hadrone-container",
212 style: "{container_style}",
213 "data-active": "{is_active}",
214 role: "application",
215 aria_label: "Draggable grid layout. Use Tab to reach widgets and resize handles. Arrow keys move the focused widget when keyboard nudge is enabled.",
216 onmounted: move |evt| {
217 #[cfg(target_arch = "wasm32")]
218 if let Some(el) = evt.data().downcast::<web_sys::Element>() {
219 wasm_attach_resize_width_observer(el.clone(), container_width);
220 }
221 #[cfg(not(target_arch = "wasm32"))]
222 drop(evt);
223 },
224
225 onpointermove: move |e: Event<PointerData>| {
227 if let Some(interaction) = active.read().as_ref() {
228 let coords = e.data.client_coordinates();
229 visual_delta.set(Some(interaction.get_visual_delta((coords.x as f32, coords.y as f32))));
230
231 #[cfg(target_arch = "wasm32")]
233 {
234 let y = coords.y as f32;
235 if y < 100.0 {
236 let _ = document::eval("window.scrollBy(0, -10)");
237 } else {
238 let _ = document::eval(&format!(r#"if (window.innerHeight - {} < 100) window.scrollBy(0, 10);"#, y));
239 }
240 }
241
242 let mut new_layout = layout.peek().clone();
243 interaction.update(
244 (coords.x as f32, coords.y as f32),
245 &mut new_layout,
246 config.cols,
247 );
248
249 if new_layout != *layout.peek() {
250 layout.set(new_layout);
251 }
252
253 if emit_interaction_updates
254 && let Some(ref h) = on_layout_event
255 && let Some(interaction) = active.read().as_ref()
256 {
257 h.call(LayoutEvent::Interaction {
258 phase: InteractionPhase::Update,
259 id: interaction.id.clone(),
260 interaction: interaction.interaction_type,
261 layout: layout.peek().clone(),
262 compaction,
263 collision: collision_strategy,
264 });
265 }
266 }
267 },
268 onpointerup: move |e| {
269 let ended = active.read().as_ref().cloned();
270 if let Some(interaction) = ended {
271 if let Some(ref h) = on_layout_event {
272 h.call(LayoutEvent::Interaction {
273 phase: InteractionPhase::Stop,
274 id: interaction.id.clone(),
275 interaction: interaction.interaction_type,
276 layout: layout.peek().clone(),
277 compaction,
278 collision: collision_strategy,
279 });
280 }
281 let pid = e.data.pointer_id();
282 let _ = document::eval(&format!(r#"
283 const container = document.querySelector(".hadrone-container[data-active='true']");
284 if (container) container.releasePointerCapture({});
285 "#, pid));
286 active.set(None);
287 visual_delta.set(None);
288 }
289 },
290 onpointerleave: move |_| {
291 let ended = active.read().as_ref().cloned();
292 if let Some(interaction) = ended {
293 if let Some(ref h) = on_layout_event {
294 h.call(LayoutEvent::Interaction {
295 phase: InteractionPhase::Cancel,
296 id: interaction.id.clone(),
297 interaction: interaction.interaction_type,
298 layout: layout.peek().clone(),
299 compaction,
300 collision: collision_strategy,
301 });
302 }
303 active.set(None);
304 visual_delta.set(None);
305 }
306 },
307 onpointercancel: move |_| {
308 let ended = active.read().as_ref().cloned();
309 if let Some(interaction) = ended {
310 if let Some(ref h) = on_layout_event {
311 h.call(LayoutEvent::Interaction {
312 phase: InteractionPhase::Cancel,
313 id: interaction.id.clone(),
314 interaction: interaction.interaction_type,
315 layout: layout.peek().clone(),
316 compaction,
317 collision: collision_strategy,
318 });
319 }
320 active.set(None);
321 visual_delta.set(None);
322 }
323 },
324
325 style {
327 r#"
328 .resize-handle {{ opacity: 0; pointer-events: none; transition: opacity 0.15s ease-in-out; }}
329 .grid-item:hover .resize-handle {{ opacity: 1; pointer-events: auto; }}
330 .hadrone-container[data-active="true"] {{ cursor: grabbing !important; }}
331 .hadrone-container[data-active="true"] .grid-item:not([data-active="true"]) .resize-handle {{ opacity: 0 !important; pointer-events: none !important; }}
332 .grid-item[data-active="true"] .resize-handle {{ opacity: 1 !important; pointer-events: auto !important; }}
333 .grid-item-inner:focus-visible {{ outline: 2px solid #2563eb; outline-offset: 2px; }}
334 .resize-handle:focus-visible {{ opacity: 1 !important; pointer-events: auto !important; outline: 2px solid #2563eb; outline-offset: 2px; }}
335 "#
336 }
337
338 for item in current_layout {
339 {
340 let item_drag = item.clone();
341 let item_resize = item.clone();
342
343 let active_ref = active.read();
344 let is_active = active_ref.as_ref().is_some_and(|a| a.id == item.id);
345
346 rsx! {
347 GridItem {
348 key: "{item.id}",
349 item: item.clone(),
350 config,
351 is_active,
352 start_rect: if is_active { active_ref.as_ref().map(|a| a.start_rect) } else { None },
353 visual_delta: if is_active { visual_delta() } else { None },
354 render_item: props.render_item,
355 layout,
356 keyboard_cell_nudge,
357 compaction,
358 interaction_active,
359 on_drag_start: move |e: Event<PointerData>| {
360 if !item_drag.can_drag() {
361 return;
362 }
363 let pid = e.data.pointer_id();
364 let _ = document::eval(&format!(r#"
365 const container = document.querySelector(".hadrone-container");
366 if (container) container.setPointerCapture({});
367 "#, pid));
368
369 let start_mouse = (e.data.client_coordinates().x as f32, e.data.client_coordinates().y as f32);
370 let session = InteractionSession {
371 id: item_drag.id.clone(),
372 start_mouse,
373 start_rect: (item_drag.x, item_drag.y, item_drag.w, item_drag.h),
374 interaction_type: InteractionType::Drag,
375 handle: ResizeHandle::SouthEast,
376 col_width_px: container_width() / config.cols as f32,
377 row_height_px: config.row_height,
378 margin: config.margin,
379 container_padding: config.container_padding,
380 compaction,
381 collision: collision_strategy,
382 };
383
384 visual_delta.set(Some(session.get_visual_delta(start_mouse)));
385 active.set(Some(session));
386 if let Some(ref h) = on_layout_event {
387 h.call(LayoutEvent::Interaction {
388 phase: InteractionPhase::Start,
389 id: item_drag.id.clone(),
390 interaction: InteractionType::Drag,
391 layout: layout.peek().clone(),
392 compaction,
393 collision: collision_strategy,
394 });
395 }
396 },
397 on_resize_start: move |(e, handle): (Event<PointerData>, ResizeHandle)| {
398 if !item_resize.can_resize() {
399 return;
400 }
401 let pid = e.data.pointer_id();
402 let _ = document::eval(&format!(r#"
403 const container = document.querySelector(".hadrone-container");
404 if (container) container.setPointerCapture({});
405 "#, pid));
406
407 let start_mouse = (e.data.client_coordinates().x as f32, e.data.client_coordinates().y as f32);
408 let session = InteractionSession {
409 id: item_resize.id.clone(),
410 start_mouse,
411 start_rect: (item_resize.x, item_resize.y, item_resize.w, item_resize.h),
412 interaction_type: InteractionType::Resize,
413 handle,
414 col_width_px: container_width() / config.cols as f32,
415 row_height_px: config.row_height,
416 margin: config.margin,
417 container_padding: config.container_padding,
418 compaction,
419 collision: collision_strategy,
420 };
421
422 visual_delta.set(Some(session.get_visual_delta(start_mouse)));
423 active.set(Some(session));
424 if let Some(ref h) = on_layout_event {
425 h.call(LayoutEvent::Interaction {
426 phase: InteractionPhase::Start,
427 id: item_resize.id.clone(),
428 interaction: InteractionType::Resize,
429 layout: layout.peek().clone(),
430 compaction,
431 collision: collision_strategy,
432 });
433 }
434 }
435 }
436 }
437 }
438 }
439 }
440 }
441}
442
443pub type PointerEvent = Event<PointerData>;
444
445#[derive(Props, Clone, PartialEq)]
446#[allow(unpredictable_function_pointer_comparisons)]
447pub struct GridItemProps {
448 pub item: LayoutItem,
449 pub config: GridConfig,
450 pub is_active: bool,
451 pub start_rect: Option<(i32, i32, i32, i32)>,
452 pub visual_delta: Option<(f32, f32, f32, f32)>,
453 pub layout: Signal<Vec<LayoutItem>>,
454 pub keyboard_cell_nudge: bool,
455 pub compaction: CompactionType,
456 pub interaction_active: bool,
457 pub render_item: fn(LayoutItem) -> Element,
458 pub on_drag_start: EventHandler<PointerEvent>,
459 pub on_resize_start: EventHandler<(PointerEvent, ResizeHandle)>,
460}
461
462#[component]
464pub fn GridItem(props: GridItemProps) -> Element {
465 let item = props.item.clone();
466 let item_id = item.id.clone();
467 let config = props.config;
468 let layout_sig = props.layout;
469 let keyboard_cell_nudge = props.keyboard_cell_nudge;
470 let compaction = props.compaction;
471 let interaction_active = props.interaction_active;
472
473 let mut x_anim = use_animation(item.x as f32, Duration::from_millis(200));
474 let mut y_anim = use_animation(item.y as f32, Duration::from_millis(200));
475 let mut w_anim = use_animation(item.w as f32, Duration::from_millis(200));
476 let mut h_anim = use_animation(item.h as f32, Duration::from_millis(200));
477
478 use_effect(move || {
479 x_anim.set(item.x as f32);
480 y_anim.set(item.y as f32);
481 w_anim.set(item.w as f32);
482 h_anim.set(item.h as f32);
483 });
484
485 let col_width_pct = 100.0 / config.cols as f32;
486
487 let (left_str, top_str, width_str, height_str) = if let (
488 Some((dx, dy, dw, dh)),
489 Some(start_rect),
490 ) = (props.visual_delta, props.start_rect)
491 {
492 let start_left_pct = start_rect.0 as f32 * col_width_pct;
493 let start_top_px = start_rect.1 as f32 * (config.row_height + config.margin.1 as f32);
494 let start_width_pct = start_rect.2 as f32 * col_width_pct;
495 let start_height_px = start_rect.3 as f32 * config.row_height
496 + (start_rect.3 as f32 - 1.0) * config.margin.1 as f32;
497
498 (
499 format!("calc({}% + {}px)", start_left_pct, dx),
500 format!("{}px", start_top_px + dy),
501 format!(
502 "calc({}% - {}px + {}px)",
503 start_width_pct, config.margin.0, dw
504 ),
505 format!("{}px", start_height_px + dh),
506 )
507 } else {
508 (
509 format!("{}%", x_anim.value() * col_width_pct),
510 format!(
511 "{}px",
512 y_anim.value() * (config.row_height + config.margin.1 as f32)
513 ),
514 format!(
515 "calc({}% - {}px)",
516 w_anim.value() * col_width_pct,
517 config.margin.0
518 ),
519 format!(
520 "{}px",
521 h_anim.value() * config.row_height
522 + (h_anim.value() - 1.0) * config.margin.1 as f32
523 ),
524 )
525 };
526
527 let transform = if props.is_active {
528 "scale(1.025) translate3d(0, 0, 0)"
529 } else {
530 "scale(1.0) translate3d(0, 0, 0)"
531 };
532
533 let style = format!(
534 "position: absolute; \
535 left: {left_str}; \
536 top: {top_str}; \
537 width: {width_str}; \
538 height: {height_str}; \
539 z-index: {z}; \
540 pointer-events: auto; \
541 transform: {transform}; \
542 transition: transform 0.15s ease-out; \
543 touch-action: none; \
544 user-select: none;",
545 z = if props.is_active { 100 } else { 0 }
546 );
547
548 let grabbed = if props.is_active { "true" } else { "false" };
549 let aria_item = format!("Widget {}, draggable grid item", item.id);
550
551 rsx! {
552 div {
553 class: "grid-item",
554 style: "{style}",
555 "data-active": "{props.is_active}",
556 div {
557 class: "grid-item-inner",
558 style: "width: 100%; height: 100%; position: relative;",
559 tabindex: 0,
560 role: "group",
561 aria_label: "{aria_item}",
562 aria_grabbed: "{grabbed}",
563 onpointerdown: move |e| props.on_drag_start.call(e),
564 onkeydown: move |e: Event<KeyboardData>| {
565 if !keyboard_cell_nudge || interaction_active {
566 return;
567 }
568 let (dx, dy) = match e.key() {
569 Key::ArrowLeft => (-1, 0),
570 Key::ArrowRight => (1, 0),
571 Key::ArrowUp => (0, -1),
572 Key::ArrowDown => (0, 1),
573 _ => return,
574 };
575 e.prevent_default();
576 e.stop_propagation();
577 apply_keyboard_cell_nudge(
578 layout_sig,
579 config.cols,
580 compaction,
581 &item_id,
582 dx,
583 dy,
584 );
585 },
586
587 { (props.render_item)(item.clone()) }
588 }
589 for handle in item
590 .resize_handles
591 .iter()
592 .cloned()
593 .filter(|h| {
594 item.can_resize()
595 && matches!(
596 h,
597 ResizeHandle::SouthEast | ResizeHandle::South | ResizeHandle::East
598 )
599 })
600 {
601 ResizeHandleComponent {
602 handle,
603 is_active: props.is_active,
604 on_pointerdown: move |e| props.on_resize_start.call((e, handle)),
605 }
606 }
607 }
608 }
609}
610
611#[component]
612fn ResizeHandleComponent(
613 handle: ResizeHandle,
614 is_active: bool,
615 on_pointerdown: EventHandler<PointerEvent>,
616) -> Element {
617 let (style, content, z) = match handle {
618 ResizeHandle::SouthEast => (
619 "bottom: -8px; right: -8px; cursor: nwse-resize; width: 40px; height: 40px; display: flex; align-items: flex-end; justify-content: flex-end; padding: 12px;",
620 rsx! {
621 svg {
622 width: "14",
623 height: "14",
624 view_box: "0 0 12 12",
625 style: "opacity: 0.2; pointer-events: none;",
626 path { d: "M10 2 L10 10 L2 10 Z", fill: "currentColor" }
627 }
628 },
629 20,
630 ),
631 ResizeHandle::South => (
632 "bottom: -8px; left: 10px; right: 30px; height: 16px; cursor: ns-resize; display: flex; justify-content: center; align-items: center;",
633 rsx! { div { style: "width: 40px; height: 4px; background: transparent; border-radius: 2px;" } },
634 10,
635 ),
636 ResizeHandle::East => (
637 "top: 10px; bottom: 30px; right: -8px; width: 16px; cursor: ew-resize; display: flex; align-items: center; justify-content: center;",
638 rsx! { div { style: "width: 4px; height: 40px; background: transparent; border-radius: 2px;" } },
639 10,
640 ),
641 _ => return rsx! {},
642 };
643
644 let active_style = if is_active {
645 "opacity: 1 !important; pointer-events: auto !important;"
646 } else {
647 ""
648 };
649 let label = resize_handle_aria_label(handle);
650
651 rsx! {
652 div {
653 class: "resize-handle",
654 style: "position: absolute; {style}; touch-action: none; z-index: {z}; {active_style}",
655 tabindex: 0,
656 role: "button",
657 aria_label: "{label}",
658 onpointerdown: move |e| on_pointerdown.call(e),
659 {content}
660 }
661 }
662}
663
664fn use_animation(target: f32, _duration: std::time::Duration) -> Animation {
665 let mut value = use_signal(|| target);
666 let mut last_target = use_signal(|| target);
667
668 if target != *last_target.read() {
669 value.set(target);
670 last_target.set(target);
671 }
672
673 Animation { value }
674}
675
676#[derive(Clone, Copy)]
677struct Animation {
678 value: Signal<f32>,
679}
680
681impl Animation {
682 fn value(&self) -> f32 {
683 *self.value.read()
684 }
685 fn set(&mut self, target: f32) {
686 self.value.set(target)
687 }
688}