tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Sutichaykuna

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/vista/vista-web/src/lib.rs Rust · 292 siq'ikuna · 10.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(gioser/web): fix mobile swipe, taskbar agnóstica, trazos zodiacales 772801301 · sergio ·

1//! Vista — deck horizontal de páginas con swipe estilo Flutter `PageView`.
2//!
3//! Agnóstico del dominio: opera sobre un elemento DOM "strip" cuyos hijos
4//! son las páginas. Cada hijo debe tener `flex: 0 0 100%` o equivalente
5//! para que el deck pueda calcular el offset correcto.
6//!
7//! Comportamiento:
8//! - Drag horizontal (mouse o touch) → strip se traslada con el dedo.
9//! - Release → snap a la página más cercana, animación CSS.
10//! - `goto(idx)` → scrolla programáticamente (click en tabs externos).
11//! - Diferencia entre gesto vertical y horizontal: si el primer movimiento
12//!   es vertical, cede al native scroll del contenido (cada página puede
13//!   tener su propio overflow-y).
14//!
15//! No inyecta CSS — el caller provee:
16//! ```css
17//! .vista-deck { overflow: hidden; touch-action: pan-y; }
18//! .vista-strip {
19//!     display: flex;
20//!     width: 100%;
21//!     height: 100%;
22//!     transform: translate3d(var(--vista-offset, 0px), 0, 0);
23//!     transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
24//! }
25//! .vista-strip.vista-dragging,
26//! .vista-strip.vista-instant { transition: none; }
27//! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; }
28//! ```
29
30use std::cell::RefCell;
31use std::rc::Rc;
32
33use wasm_bindgen::prelude::*;
34use wasm_bindgen::JsCast;
35use web_sys::{Event, HtmlElement, PointerEvent};
36
37/// Umbral en pixels para confirmar gesto horizontal vs vertical.
38const DRAG_DECISION_PX: f64 = 8.0;
39/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
40const HORIZONTAL_BIAS: f64 = 1.3;
41
42#[derive(Clone)]
43pub struct Deck {
44    strip: HtmlElement,
45    inner: Rc<RefCell<Inner>>,
46}
47
48struct Inner {
49    current_index: usize,
50    pointer_start: Option<(f64, f64, i32)>, // (x, y, pointer_id)
51    drag_active: bool,
52    drag_start_offset: f64,
53    on_change: Option<Box<dyn FnMut(usize)>>,
54}
55
56impl Deck {
57    /// Monta el deck sobre el `strip`. El strip debe tener
58    /// `display: flex; transform: translate3d(var(--vista-offset, 0px), ...);`
59    /// y children con `flex: 0 0 100%`.
60    pub fn mount(strip: HtmlElement) -> Result<Self, JsValue> {
61        let inner = Rc::new(RefCell::new(Inner {
62            current_index: 0,
63            pointer_start: None,
64            drag_active: false,
65            drag_start_offset: 0.0,
66            on_change: None,
67        }));
68
69        install_pointerdown(&strip, &inner)?;
70        install_pointermove(&strip, &inner)?;
71        install_pointerend(&strip, &inner, "pointerup")?;
72        install_pointerend(&strip, &inner, "pointercancel")?;
73        install_pointerend(&strip, &inner, "pointerleave")?;
74        install_resize(&strip, &inner)?;
75
76        Ok(Self { strip, inner })
77    }
78
79    /// Navega a la página por índice. Con `smooth=false` se aplica
80    /// `.vista-instant` un frame para saltar sin transición.
81    pub fn goto(&self, index: usize, smooth: bool) {
82        let width = self.strip.client_width() as f64;
83        let offset = -(index as f64) * width;
84        if !smooth {
85            let _ = self.strip.class_list().add_1("vista-instant");
86        }
87        let _ = self
88            .strip
89            .style()
90            .set_property("--vista-offset", &format!("{}px", offset));
91        if !smooth {
92            // Quitamos `vista-instant` en el siguiente frame para restaurar la
93            // transición ordinaria.
94            let strip = self.strip.clone();
95            let cb = Closure::once(Box::new(move || {
96                let _ = strip.class_list().remove_1("vista-instant");
97            }) as Box<dyn FnOnce()>);
98            if let Some(w) = web_sys::window() {
99                let _ = w.request_animation_frame(cb.as_ref().unchecked_ref());
100            }
101            cb.forget();
102        }
103        let mut i = self.inner.borrow_mut();
104        let changed = i.current_index != index;
105        i.current_index = index;
106        if changed {
107            if let Some(cb) = i.on_change.as_mut() {
108                cb(index);
109            }
110        }
111    }
112
113    /// Índice de página actualmente visible.
114    pub fn current_index(&self) -> usize {
115        self.inner.borrow().current_index
116    }
117
118    /// Cantidad de páginas hijas del strip (live: lee el DOM).
119    pub fn page_count(&self) -> u32 {
120        self.strip.child_element_count()
121    }
122
123    /// Registra (o reemplaza) el callback de cambio de página. Se dispara
124    /// tras el snap de un swipe, o tras un `goto()` que cambie de índice.
125    pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
126        self.inner.borrow_mut().on_change = Some(Box::new(cb));
127    }
128
129    pub fn strip(&self) -> &HtmlElement {
130        &self.strip
131    }
132}
133
134fn install_pointerdown(
135    strip: &HtmlElement,
136    inner: &Rc<RefCell<Inner>>,
137) -> Result<(), JsValue> {
138    let strip2 = strip.clone();
139    let inner2 = inner.clone();
140    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
141        let mut i = inner2.borrow_mut();
142        let width = strip2.client_width() as f64;
143        i.pointer_start = Some((
144            e.client_x() as f64,
145            e.client_y() as f64,
146            e.pointer_id(),
147        ));
148        i.drag_active = false;
149        i.drag_start_offset = -(i.current_index as f64) * width;
150    });
151    strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
152    cb.forget();
153    Ok(())
154}
155
156fn install_pointermove(
157    strip: &HtmlElement,
158    inner: &Rc<RefCell<Inner>>,
159) -> Result<(), JsValue> {
160    let strip2 = strip.clone();
161    let inner2 = inner.clone();
162    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
163        let mut i = inner2.borrow_mut();
164        let Some((sx, sy, pid)) = i.pointer_start else {
165            return;
166        };
167        let dx = e.client_x() as f64 - sx;
168        let dy = e.client_y() as f64 - sy;
169        if !i.drag_active {
170            let abs_dx = dx.abs();
171            let abs_dy = dy.abs();
172            if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
173                // Empieza drag horizontal.
174                i.drag_active = true;
175                let _ = strip2.class_list().add_1("vista-dragging");
176                // Capturar pointer para que los `move` sigan llegando aunque
177                // el cursor se vaya del strip.
178                let _ = strip2.set_pointer_capture(pid);
179            } else if abs_dy > DRAG_DECISION_PX {
180                // Movimiento vertical predominante — cancelar este drag.
181                i.pointer_start = None;
182                return;
183            } else {
184                return;
185            }
186        }
187        if i.drag_active {
188            let offset = i.drag_start_offset + dx;
189            let _ = strip2
190                .style()
191                .set_property("--vista-offset", &format!("{}px", offset));
192            // CRÍTICO en móvil: con listener passive el preventDefault sería
193            // un no-op y el browser se llevaría el gesto como pan/scroll.
194            e.prevent_default();
195        }
196    });
197    // `passive: false` es la diferencia entre que el swipe funcione o no en
198    // navegadores móviles. Default = passive, donde preventDefault no aplica.
199    let opts = web_sys::AddEventListenerOptions::new();
200    opts.set_passive(false);
201    strip.add_event_listener_with_callback_and_add_event_listener_options(
202        "pointermove",
203        cb.as_ref().unchecked_ref(),
204        &opts,
205    )?;
206    cb.forget();
207    Ok(())
208}
209
210fn install_pointerend(
211    strip: &HtmlElement,
212    inner: &Rc<RefCell<Inner>>,
213    event_name: &str,
214) -> Result<(), JsValue> {
215    let strip2 = strip.clone();
216    let inner2 = inner.clone();
217    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
218        let mut i = inner2.borrow_mut();
219        let was_active = i.drag_active;
220        i.drag_active = false;
221        i.pointer_start = None;
222        if !was_active {
223            return;
224        }
225        let _ = strip2.class_list().remove_1("vista-dragging");
226        let _ = strip2.release_pointer_capture(e.pointer_id());
227        let width = strip2.client_width() as f64;
228        let offset = current_offset_px(&strip2);
229        let n_pages = strip2.child_element_count() as usize;
230        if width <= 0.0 || n_pages == 0 {
231            return;
232        }
233        // Snap a la página más cercana (clamp a [0, n-1]).
234        let raw = -offset / width;
235        let target = raw.round().max(0.0) as usize;
236        let target = target.min(n_pages - 1);
237        let snapped_offset = -(target as f64) * width;
238        let _ = strip2
239            .style()
240            .set_property("--vista-offset", &format!("{}px", snapped_offset));
241        let changed = i.current_index != target;
242        i.current_index = target;
243        if changed {
244            if let Some(cb) = i.on_change.as_mut() {
245                cb(target);
246            }
247        }
248    });
249    strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
250    cb.forget();
251    Ok(())
252}
253
254fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
255    let Some(window) = web_sys::window() else {
256        return Ok(());
257    };
258    let strip2 = strip.clone();
259    let inner2 = inner.clone();
260    let cb = Closure::<dyn FnMut()>::new(move || {
261        // Reposicionar sin animación para que un resize no cause un slide.
262        let idx = inner2.borrow().current_index;
263        let _ = strip2.class_list().add_1("vista-instant");
264        let width = strip2.client_width() as f64;
265        let offset = -(idx as f64) * width;
266        let _ = strip2
267            .style()
268            .set_property("--vista-offset", &format!("{}px", offset));
269        let strip_for_clear = strip2.clone();
270        let cb2 = Closure::once(Box::new(move || {
271            let _ = strip_for_clear.class_list().remove_1("vista-instant");
272        }) as Box<dyn FnOnce()>);
273        if let Some(w) = web_sys::window() {
274            let _ = w.request_animation_frame(cb2.as_ref().unchecked_ref());
275        }
276        cb2.forget();
277    });
278    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
279    cb.forget();
280    Ok(())
281}
282
283/// Lee `--vista-offset` del strip como número de pixels (default 0).
284fn current_offset_px(strip: &HtmlElement) -> f64 {
285    let s = strip.style().get_property_value("--vista-offset").unwrap_or_default();
286    let trimmed = s.trim().trim_end_matches("px");
287    trimmed.parse::<f64>().unwrap_or(0.0)
288}
289
290/// Suprime warnings de uso no-utilizado durante compilación host (no-WASM).
291#[doc(hidden)]
292pub fn __unused_event_marker(_e: &Event) {}