//! Formatting + small browser helpers. use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; /// A window event listener that is removed when the current owner is disposed. /// /// Leptos' `window_event_listener` returns a `WindowListenerHandle` with no /// `Drop` impl — if the owning component is unmounted without an explicit /// `remove()`, the listener keeps firing on `window` and panics by reading /// the component's now-disposed signals. pub fn owned_window_listener( event: E, cb: impl Fn(E::EventType) + 'static, ) where E::EventType: wasm_bindgen::JsCast, { let handle = window_event_listener(event, cb); on_cleanup(move || handle.remove()); } /// A touch long press (500 ms without lifting or moving) that stands in for /// a right click. Android fires `contextmenu` on a long press by itself; /// iOS Safari does not, so the pointer events are watched directly. /// /// One instance per element. Wire `down`, `up`, `mv` to `pointerdown`, /// `pointerup` + `pointercancel` + `pointerleave`, and `pointermove`. The /// element's `click` handler must call [`LongPress::swallow_click`] first: /// the browser still sends the click of the finger lifting after the menu /// opened, and that click would close the menu again. #[derive(Clone, Copy)] pub struct LongPress { inner: StoredValue, } #[derive(Default)] struct LongPressState { timer: Option, start: (i32, i32), fired: bool, } impl LongPress { pub const DELAY_MS: u32 = 500; /// Finger travel that turns the press into a scroll. const SLOP_PX: i32 = 10; pub fn new() -> Self { Self { inner: StoredValue::new_local(LongPressState::default()), } } /// `pointerdown`: arm the timer for touch pointers; `on_fire` gets the /// client coordinates. Stops propagation so a nested element and its /// container do not both arm. pub fn down(&self, ev: &web_sys::PointerEvent, on_fire: impl Fn(i32, i32) + 'static) { if ev.pointer_type() != "touch" { return; } ev.stop_propagation(); let (x, y) = (ev.client_x(), ev.client_y()); let inner = self.inner; let timer = gloo_timers::callback::Timeout::new(Self::DELAY_MS, move || { inner.update_value(|st| { st.timer = None; st.fired = true; }); on_fire(x, y); }); self.inner.update_value(|st| { st.timer = Some(timer); st.start = (x, y); st.fired = false; }); } /// `pointerup`, `pointercancel`, `pointerleave`: disarm. pub fn up(&self) { self.inner.update_value(|st| st.timer = None); } /// `pointermove`: disarm once the finger travels; that is a scroll. pub fn mv(&self, ev: &web_sys::PointerEvent) { self.inner.update_value(|st| { if st.timer.is_none() { return; } let (sx, sy) = st.start; if (ev.client_x() - sx).abs() > Self::SLOP_PX || (ev.client_y() - sy).abs() > Self::SLOP_PX { st.timer = None; } }); } /// True once, for the click that follows a fired press. The caller /// stops that click instead of handling it. pub fn swallow_click(&self) -> bool { self.inner .try_update_value(|st| std::mem::take(&mut st.fired)) .unwrap_or(false) } } /// Same as [`owned_window_listener`], but registered in the capture phase, /// so it runs before the bubble-phase window listeners (registration order /// otherwise decides, and that depends on component creation order). pub fn owned_window_listener_capture( event: E, cb: impl Fn(E::EventType) + 'static, ) where E::EventType: wasm_bindgen::JsCast, { let closure = Closure::::new({ move |e: web_sys::Event| cb(e.unchecked_into::()) }); let add_options = web_sys::AddEventListenerOptions::new(); add_options.set_capture(true); let remove_options = web_sys::EventListenerOptions::new(); remove_options.set_capture(true); let name = event.name(); web_sys::window() .expect("window") .add_event_listener_with_callback_and_add_event_listener_options( &name, closure.as_ref().unchecked_ref(), &add_options, ) .expect("failed to add window event listener"); let closure_js = closure.into_js_value(); on_cleanup(move || { let closure: js_sys::Function = closure_js.unchecked_into(); let _ = web_sys::window() .expect("window") .remove_event_listener_with_callback_and_event_listener_options( &name, &closure, &remove_options, ); }); } /// Select the whole value of the `` behind an event, so one click /// leaves the link ready to copy by hand. pub fn select_input(ev: &web_sys::Event) { if let Some(i) = ev .target() .and_then(|t| t.dyn_into::().ok()) { i.select(); } } /// The value of a `