Skip to main content

logicaffeine_web/ui/
drag.rs

1//! Pointer drags that follow the pointer at frame rate without re-rendering the page.
2//!
3//! A drag captures the pointer on its handle, so it keeps tracking over iframes and outside
4//! the window, and reports movement at most once per animation frame. The caller paints each
5//! frame straight onto the DOM (a CSS custom property or an inline size) and commits the final
6//! value to its signal once, when the pointer is released. Re-rendering a component as large
7//! as the Studio on every pointer event is what made its resizers lag.
8
9/// The direction a drag resizes, which sets the cursor shown everywhere while it lasts.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Axis {
12    /// Resizing widths: a column boundary.
13    Columns,
14    /// Resizing heights: a row boundary.
15    Rows,
16}
17
18impl Axis {
19    /// The class the document root carries while a drag on this axis is live.
20    pub fn root_class(self) -> &'static str {
21        match self {
22            Axis::Columns => "dragging-columns",
23            Axis::Rows => "dragging-rows",
24        }
25    }
26}
27
28/// While a drag is live: no text selection anywhere, the resize cursor everywhere, and no
29/// iframe (the Web preview) swallowing pointer events.
30pub const DRAG_STYLE: &str = r#"
31html.dragging, html.dragging * { user-select: none !important; -webkit-user-select: none !important; }
32html.dragging iframe { pointer-events: none !important; }
33html.dragging-columns, html.dragging-columns * { cursor: col-resize !important; }
34html.dragging-rows, html.dragging-rows * { cursor: row-resize !important; }
35"#;
36
37#[cfg(target_arch = "wasm32")]
38pub use live::begin;
39
40#[cfg(target_arch = "wasm32")]
41mod live {
42    use super::Axis;
43    use std::cell::RefCell;
44    use std::rc::Rc;
45    use wasm_bindgen::prelude::*;
46    use wasm_bindgen::JsCast;
47
48    const ENDS: [&str; 3] = ["pointerup", "pointercancel", "lostpointercapture"];
49
50    struct Live {
51        handle: web_sys::Element,
52        axis: Axis,
53        origin: (f64, f64),
54        latest: (f64, f64),
55        frame: Option<i32>,
56        on_frame: Box<dyn FnMut(f64, f64)>,
57        on_end: Option<Box<dyn FnOnce(f64, f64)>>,
58        on_move: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
59        on_stop: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
60        paint: Option<Closure<dyn FnMut()>>,
61    }
62
63    fn root() -> Option<web_sys::Element> {
64        web_sys::window()?.document()?.document_element()
65    }
66
67    /// Start a drag on `handle` for the pointer that just went down at `origin` (client
68    /// coordinates). `on_frame` receives the offset from `origin` at most once per animation
69    /// frame; `on_end` receives the final offset once, when the pointer is released.
70    pub fn begin(
71        handle: web_sys::Element,
72        pointer_id: i32,
73        origin: (f64, f64),
74        axis: Axis,
75        on_frame: impl FnMut(f64, f64) + 'static,
76        on_end: impl FnOnce(f64, f64) + 'static,
77    ) {
78        let _ = handle.set_pointer_capture(pointer_id);
79        if let Some(root) = root() {
80            let _ = root.class_list().add_2("dragging", axis.root_class());
81        }
82
83        let live = Rc::new(RefCell::new(Live {
84            handle: handle.clone(),
85            axis,
86            origin,
87            latest: origin,
88            frame: None,
89            on_frame: Box::new(on_frame),
90            on_end: Some(Box::new(on_end)),
91            on_move: None,
92            on_stop: None,
93            paint: None,
94        }));
95
96        let weak = Rc::downgrade(&live);
97        let paint = Closure::<dyn FnMut()>::new(move || {
98            let Some(live) = weak.upgrade() else { return };
99            let mut live = live.borrow_mut();
100            live.frame = None;
101            let (x, y) = live.latest;
102            let (ox, oy) = live.origin;
103            (live.on_frame)(x - ox, y - oy);
104        });
105
106        let weak = Rc::downgrade(&live);
107        let on_move = Closure::<dyn FnMut(web_sys::PointerEvent)>::new(move |e: web_sys::PointerEvent| {
108            let Some(live) = weak.upgrade() else { return };
109            let mut live = live.borrow_mut();
110            live.latest = (e.client_x() as f64, e.client_y() as f64);
111            if live.frame.is_none() {
112                let request = live
113                    .paint
114                    .as_ref()
115                    .and_then(|paint| web_sys::window()?.request_animation_frame(paint.as_ref().unchecked_ref()).ok());
116                live.frame = request;
117            }
118        });
119
120        let weak = Rc::downgrade(&live);
121        let on_stop = Closure::<dyn FnMut(web_sys::PointerEvent)>::new(move |e: web_sys::PointerEvent| {
122            let Some(live) = weak.upgrade() else { return };
123            if e.type_() != "lostpointercapture" {
124                live.borrow_mut().latest = (e.client_x() as f64, e.client_y() as f64);
125            }
126            finish(live);
127        });
128
129        let _ = handle.add_event_listener_with_callback("pointermove", on_move.as_ref().unchecked_ref());
130        for end in ENDS {
131            let _ = handle.add_event_listener_with_callback(end, on_stop.as_ref().unchecked_ref());
132        }
133
134        let mut state = live.borrow_mut();
135        state.paint = Some(paint);
136        state.on_move = Some(on_move);
137        state.on_stop = Some(on_stop);
138        drop(state);
139        // The listeners hold only weak references; this strong one keeps the drag alive until
140        // `finish` releases it.
141        LIVE.with(|slot| *slot.borrow_mut() = Some(live));
142    }
143
144    thread_local! {
145        static LIVE: RefCell<Option<Rc<RefCell<Live>>>> = const { RefCell::new(None) };
146    }
147
148    fn finish(live: Rc<RefCell<Live>>) {
149        let (on_end, offset) = {
150            let mut state = live.borrow_mut();
151            let Some(on_end) = state.on_end.take() else { return };
152            if let (Some(id), Some(window)) = (state.frame.take(), web_sys::window()) {
153                let _ = window.cancel_animation_frame(id);
154            }
155            if let Some(on_move) = &state.on_move {
156                let _ = state.handle.remove_event_listener_with_callback("pointermove", on_move.as_ref().unchecked_ref());
157            }
158            if let Some(on_stop) = &state.on_stop {
159                for end in ENDS {
160                    let _ = state.handle.remove_event_listener_with_callback(end, on_stop.as_ref().unchecked_ref());
161                }
162            }
163            if let Some(root) = root() {
164                let _ = root.class_list().remove_2("dragging", state.axis.root_class());
165            }
166            let (x, y) = state.latest;
167            let (ox, oy) = state.origin;
168            (on_end, (x - ox, y - oy))
169        };
170        on_end(offset.0, offset.1);
171        // A listener cannot free the closure that is running it: drop the drag once this
172        // event has finished dispatching.
173        LIVE.with(|slot| {
174            let done = slot.borrow_mut().take();
175            wasm_bindgen_futures::spawn_local(async move { drop(done) });
176        });
177        drop(live);
178    }
179}
180
181#[cfg(test)]
182#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/drag/tests.rs"]
183mod tests;