⎇ 62058ab19302c95a7e1ad2b379769ed0bc152533
crates/modules/vista/vista-web/src/lib.rs
Historia
Quién tocó qué
crudo
feat(gioser): deck swipeable + minimize, brand+copyleft en taskbar (vista-web)
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) {}