//! Vista — deck horizontal de páginas con swipe estilo Flutter `PageView`. //! //! Agnóstico del dominio: opera sobre un elemento DOM "strip" cuyos hijos //! son las páginas. Cada hijo debe tener `flex: 0 0 100%` o equivalente //! para que el deck pueda calcular el offset correcto. //! //! Comportamiento: //! - Drag horizontal (mouse o touch) → strip se traslada con el dedo. //! - Release → snap a la página más cercana, animación CSS. //! - `goto(idx)` → scrolla programáticamente (click en tabs externos). //! - Diferencia entre gesto vertical y horizontal: si el primer movimiento //! es vertical, cede al native scroll del contenido (cada página puede //! tener su propio overflow-y). //! //! No inyecta CSS — el caller provee: //! ```css //! .vista-deck { overflow: hidden; touch-action: pan-y; } //! .vista-strip { //! display: flex; //! width: 100%; //! height: 100%; //! transform: translate3d(var(--vista-offset, 0px), 0, 0); //! transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1); //! } //! .vista-strip.vista-dragging, //! .vista-strip.vista-instant { transition: none; } //! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; } //! ``` use std::cell::RefCell; use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{Event, HtmlElement, PointerEvent}; /// Umbral en pixels para confirmar gesto horizontal vs vertical. const DRAG_DECISION_PX: f64 = 8.0; /// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe". const HORIZONTAL_BIAS: f64 = 1.3; #[derive(Clone)] pub struct Deck { strip: HtmlElement, inner: Rc>, } struct Inner { current_index: usize, pointer_start: Option<(f64, f64, i32)>, // (x, y, pointer_id) drag_active: bool, drag_start_offset: f64, on_change: Option>, } impl Deck { /// Monta el deck sobre el `strip`. El strip debe tener /// `display: flex; transform: translate3d(var(--vista-offset, 0px), ...);` /// y children con `flex: 0 0 100%`. pub fn mount(strip: HtmlElement) -> Result { let inner = Rc::new(RefCell::new(Inner { current_index: 0, pointer_start: None, drag_active: false, drag_start_offset: 0.0, on_change: None, })); install_pointerdown(&strip, &inner)?; install_pointermove(&strip, &inner)?; install_pointerend(&strip, &inner, "pointerup")?; install_pointerend(&strip, &inner, "pointercancel")?; install_pointerend(&strip, &inner, "pointerleave")?; install_resize(&strip, &inner)?; Ok(Self { strip, inner }) } /// Navega a la página por índice. Con `smooth=false` se aplica /// `.vista-instant` un frame para saltar sin transición. pub fn goto(&self, index: usize, smooth: bool) { let width = self.strip.client_width() as f64; let offset = -(index as f64) * width; if !smooth { let _ = self.strip.class_list().add_1("vista-instant"); } let _ = self .strip .style() .set_property("--vista-offset", &format!("{}px", offset)); if !smooth { // Quitamos `vista-instant` en el siguiente frame para restaurar la // transición ordinaria. let strip = self.strip.clone(); let cb = Closure::once(Box::new(move || { let _ = strip.class_list().remove_1("vista-instant"); }) as Box); if let Some(w) = web_sys::window() { let _ = w.request_animation_frame(cb.as_ref().unchecked_ref()); } cb.forget(); } let mut i = self.inner.borrow_mut(); let changed = i.current_index != index; i.current_index = index; if changed { if let Some(cb) = i.on_change.as_mut() { cb(index); } } } /// Índice de página actualmente visible. pub fn current_index(&self) -> usize { self.inner.borrow().current_index } /// Cantidad de páginas hijas del strip (live: lee el DOM). pub fn page_count(&self) -> u32 { self.strip.child_element_count() } /// Registra (o reemplaza) el callback de cambio de página. Se dispara /// tras el snap de un swipe, o tras un `goto()` que cambie de índice. pub fn on_change(&self, cb: F) { self.inner.borrow_mut().on_change = Some(Box::new(cb)); } pub fn strip(&self) -> &HtmlElement { &self.strip } } fn install_pointerdown( strip: &HtmlElement, inner: &Rc>, ) -> Result<(), JsValue> { let strip2 = strip.clone(); let inner2 = inner.clone(); let cb = Closure::::new(move |e: PointerEvent| { let mut i = inner2.borrow_mut(); let width = strip2.client_width() as f64; i.pointer_start = Some(( e.client_x() as f64, e.client_y() as f64, e.pointer_id(), )); i.drag_active = false; i.drag_start_offset = -(i.current_index as f64) * width; }); strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_pointermove( strip: &HtmlElement, inner: &Rc>, ) -> Result<(), JsValue> { let strip2 = strip.clone(); let inner2 = inner.clone(); let cb = Closure::::new(move |e: PointerEvent| { let mut i = inner2.borrow_mut(); let Some((sx, sy, pid)) = i.pointer_start else { return; }; let dx = e.client_x() as f64 - sx; let dy = e.client_y() as f64 - sy; if !i.drag_active { let abs_dx = dx.abs(); let abs_dy = dy.abs(); if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS { // Empieza drag horizontal. i.drag_active = true; let _ = strip2.class_list().add_1("vista-dragging"); // Capturar pointer para que los `move` sigan llegando aunque // el cursor se vaya del strip. let _ = strip2.set_pointer_capture(pid); } else if abs_dy > DRAG_DECISION_PX { // Movimiento vertical predominante — cancelar este drag. i.pointer_start = None; return; } else { return; } } if i.drag_active { let offset = i.drag_start_offset + dx; let _ = strip2 .style() .set_property("--vista-offset", &format!("{}px", offset)); // CRÍTICO en móvil: con listener passive el preventDefault sería // un no-op y el browser se llevaría el gesto como pan/scroll. e.prevent_default(); } }); // `passive: false` es la diferencia entre que el swipe funcione o no en // navegadores móviles. Default = passive, donde preventDefault no aplica. let opts = web_sys::AddEventListenerOptions::new(); opts.set_passive(false); strip.add_event_listener_with_callback_and_add_event_listener_options( "pointermove", cb.as_ref().unchecked_ref(), &opts, )?; cb.forget(); Ok(()) } fn install_pointerend( strip: &HtmlElement, inner: &Rc>, event_name: &str, ) -> Result<(), JsValue> { let strip2 = strip.clone(); let inner2 = inner.clone(); let cb = Closure::::new(move |e: PointerEvent| { let mut i = inner2.borrow_mut(); let was_active = i.drag_active; i.drag_active = false; i.pointer_start = None; if !was_active { return; } let _ = strip2.class_list().remove_1("vista-dragging"); let _ = strip2.release_pointer_capture(e.pointer_id()); let width = strip2.client_width() as f64; let offset = current_offset_px(&strip2); let n_pages = strip2.child_element_count() as usize; if width <= 0.0 || n_pages == 0 { return; } // Snap a la página más cercana (clamp a [0, n-1]). let raw = -offset / width; let target = raw.round().max(0.0) as usize; let target = target.min(n_pages - 1); let snapped_offset = -(target as f64) * width; let _ = strip2 .style() .set_property("--vista-offset", &format!("{}px", snapped_offset)); let changed = i.current_index != target; i.current_index = target; if changed { if let Some(cb) = i.on_change.as_mut() { cb(target); } } }); strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } fn install_resize(strip: &HtmlElement, inner: &Rc>) -> Result<(), JsValue> { let Some(window) = web_sys::window() else { return Ok(()); }; let strip2 = strip.clone(); let inner2 = inner.clone(); let cb = Closure::::new(move || { // Reposicionar sin animación para que un resize no cause un slide. let idx = inner2.borrow().current_index; let _ = strip2.class_list().add_1("vista-instant"); let width = strip2.client_width() as f64; let offset = -(idx as f64) * width; let _ = strip2 .style() .set_property("--vista-offset", &format!("{}px", offset)); let strip_for_clear = strip2.clone(); let cb2 = Closure::once(Box::new(move || { let _ = strip_for_clear.class_list().remove_1("vista-instant"); }) as Box); if let Some(w) = web_sys::window() { let _ = w.request_animation_frame(cb2.as_ref().unchecked_ref()); } cb2.forget(); }); window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?; cb.forget(); Ok(()) } /// Lee `--vista-offset` del strip como número de pixels (default 0). fn current_offset_px(strip: &HtmlElement) -> f64 { let s = strip.style().get_property_value("--vista-offset").unwrap_or_default(); let trimmed = s.trim().trim_end_matches("px"); trimmed.parse::().unwrap_or(0.0) } /// Suprime warnings de uso no-utilizado durante compilación host (no-WASM). #[doc(hidden)] pub fn __unused_event_marker(_e: &Event) {}