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