tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114587 Definiciones

⎇ 62058ab19302c95a7e1ad2b379769ed0bc152533 crates/modules/vista/vista-web/src/lib.rs Rust · 282 líneas · 9.9 KB Historia Quién tocó qué crudo

feat(gioser): deck swipeable + minimize, brand+copyleft en taskbar (vista-web) 62058ab19 · 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            e.prevent_default();
193        }
194    });
195    strip.add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
196    cb.forget();
197    Ok(())
198}
199
200fn install_pointerend(
201    strip: &HtmlElement,
202    inner: &Rc<RefCell<Inner>>,
203    event_name: &str,
204) -> Result<(), JsValue> {
205    let strip2 = strip.clone();
206    let inner2 = inner.clone();
207    let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
208        let mut i = inner2.borrow_mut();
209        let was_active = i.drag_active;
210        i.drag_active = false;
211        i.pointer_start = None;
212        if !was_active {
213            return;
214        }
215        let _ = strip2.class_list().remove_1("vista-dragging");
216        let _ = strip2.release_pointer_capture(e.pointer_id());
217        let width = strip2.client_width() as f64;
218        let offset = current_offset_px(&strip2);
219        let n_pages = strip2.child_element_count() as usize;
220        if width <= 0.0 || n_pages == 0 {
221            return;
222        }
223        // Snap a la página más cercana (clamp a [0, n-1]).
224        let raw = -offset / width;
225        let target = raw.round().max(0.0) as usize;
226        let target = target.min(n_pages - 1);
227        let snapped_offset = -(target as f64) * width;
228        let _ = strip2
229            .style()
230            .set_property("--vista-offset", &format!("{}px", snapped_offset));
231        let changed = i.current_index != target;
232        i.current_index = target;
233        if changed {
234            if let Some(cb) = i.on_change.as_mut() {
235                cb(target);
236            }
237        }
238    });
239    strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
240    cb.forget();
241    Ok(())
242}
243
244fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
245    let Some(window) = web_sys::window() else {
246        return Ok(());
247    };
248    let strip2 = strip.clone();
249    let inner2 = inner.clone();
250    let cb = Closure::<dyn FnMut()>::new(move || {
251        // Reposicionar sin animación para que un resize no cause un slide.
252        let idx = inner2.borrow().current_index;
253        let _ = strip2.class_list().add_1("vista-instant");
254        let width = strip2.client_width() as f64;
255        let offset = -(idx as f64) * width;
256        let _ = strip2
257            .style()
258            .set_property("--vista-offset", &format!("{}px", offset));
259        let strip_for_clear = strip2.clone();
260        let cb2 = Closure::once(Box::new(move || {
261            let _ = strip_for_clear.class_list().remove_1("vista-instant");
262        }) as Box<dyn FnOnce()>);
263        if let Some(w) = web_sys::window() {
264            let _ = w.request_animation_frame(cb2.as_ref().unchecked_ref());
265        }
266        cb2.forget();
267    });
268    window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
269    cb.forget();
270    Ok(())
271}
272
273/// Lee `--vista-offset` del strip como número de pixels (default 0).
274fn current_offset_px(strip: &HtmlElement) -> f64 {
275    let s = strip.style().get_property_value("--vista-offset").unwrap_or_default();
276    let trimmed = s.trim().trim_end_matches("px");
277    trimmed.parse::<f64>().unwrap_or(0.0)
278}
279
280/// Suprime warnings de uso no-utilizado durante compilación host (no-WASM).
281#[doc(hidden)]
282pub fn __unused_event_marker(_e: &Event) {}