Skip to main content

perspective_viewer/
dragdrop.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use std::cell::{Cell, RefCell};
14use std::ops::Deref;
15use std::rc::Rc;
16
17use perspective_client::clone;
18use wasm_bindgen::prelude::*;
19use web_sys::*;
20use yew::html::ImplicitClone;
21use yew::prelude::*;
22
23use crate::js::{IntersectionObserver, IntersectionObserverEntry};
24use crate::utils::*;
25use crate::*;
26
27/// Value-semantic snapshot of the drag/drop state threaded through the
28/// component tree for visual feedback (drag-highlight CSS classes).
29#[derive(Clone, Debug, PartialEq, Default)]
30pub struct DragDropProps {
31    /// Column name currently being dragged, if a drag is in progress.
32    pub column: Option<String>,
33}
34
35#[derive(Clone, Debug)]
36struct DragFrom {
37    column: String,
38    effect: DragEffect,
39}
40
41#[derive(Debug)]
42struct DragOver {
43    target: DragTarget,
44    index: usize,
45}
46
47#[derive(Debug, Default)]
48enum DragState {
49    #[default]
50    NoDrag,
51    DragInProgress(DragFrom),
52    DragOverInProgress(DragFrom, DragOver),
53}
54
55impl DragState {
56    const fn is_drag_in_progress(&self) -> bool {
57        !matches!(self, Self::NoDrag)
58    }
59}
60
61pub type DragEndCallback = Closure<dyn FnMut(DragEvent)>;
62
63pub struct DragDropState {
64    drag_state: RefCell<DragState>,
65    pub drop_received: PubSub<(String, DragTarget, DragEffect, usize)>,
66
67    /// Injected callback from the root component, replacing the former
68    /// `dragstart_received: PubSub` field.
69    pub on_dragstart: RefCell<Option<Callback<DragEffect>>>,
70
71    /// Injected callback from the root component, replacing the former
72    /// `dragend_received: PubSub` field.
73    pub on_dragend: RefCell<Option<Callback<()>>>,
74
75    /// The host `<perspective-viewer>` element, used to attach the fallback
76    /// `dragend` listener on a stable DOM node outside the virtual DOM.
77    elem: HtmlElement,
78
79    /// Host-level `dragend` listener closure, stored so it can be removed
80    /// when a new drag starts.  Attached to `elem` rather than `document`
81    /// to keep the listener scoped to this component instance.
82    host_dragend: RefCell<Option<DragEndCallback>>,
83
84    drag_target: RefCell<Option<DragTargetState>>,
85}
86
87/// The `<perspective-viewer>` drag/drop service, which manages drag/drop user
88/// interactions across components.  It is a component-level service, since only
89/// one drag/drop action can be executed by the user at a time.
90#[derive(Clone)]
91pub struct DragDrop(Rc<DragDropState>);
92
93impl DragDrop {
94    pub fn new(elem: &HtmlElement) -> Self {
95        Self(Rc::new(DragDropState {
96            drag_state: Default::default(),
97            drop_received: Default::default(),
98            on_dragstart: Default::default(),
99            on_dragend: Default::default(),
100            elem: elem.clone(),
101            host_dragend: Default::default(),
102            drag_target: Default::default(),
103        }))
104    }
105}
106
107impl Deref for DragDrop {
108    type Target = Rc<DragDropState>;
109
110    fn deref(&self) -> &Self::Target {
111        &self.0
112    }
113}
114
115impl PartialEq for DragDrop {
116    fn eq(&self, other: &Self) -> bool {
117        Rc::ptr_eq(&self.0, &other.0)
118    }
119}
120
121impl ImplicitClone for DragDrop {}
122
123impl DragDrop {
124    /// Snapshot the drag state as a [`DragDropProps`] value for threading
125    /// through the component tree without PubSub subscriptions.
126    pub fn to_props(&self) -> DragDropProps {
127        DragDropProps {
128            column: self.get_drag_column(),
129        }
130    }
131
132    /// Get the column name currently being drag/dropped.
133    pub fn get_drag_column(&self) -> Option<String> {
134        match *self.drag_state.borrow() {
135            DragState::DragInProgress(DragFrom { ref column, .. })
136            | DragState::DragOverInProgress(DragFrom { ref column, .. }, _) => Some(column.clone()),
137            _ => None,
138        }
139    }
140
141    pub fn get_drag_target(&self) -> Option<DragTarget> {
142        match *self.drag_state.borrow() {
143            DragState::DragInProgress(DragFrom {
144                effect: DragEffect::Move(target),
145                ..
146            })
147            | DragState::DragOverInProgress(
148                DragFrom {
149                    effect: DragEffect::Move(target),
150                    ..
151                },
152                _,
153            ) => Some(target),
154            _ => None,
155        }
156    }
157
158    pub fn set_drag_image(&self, event: &DragEvent) -> ApiResult<()> {
159        event.stop_propagation();
160        if let Some(dt) = event.data_transfer() {
161            dt.set_drop_effect("move");
162            // dt.set_data("text/plain", "{}").unwrap();
163        }
164
165        let original: HtmlElement = event.target().into_apierror()?.unchecked_into();
166        let elem: HtmlElement = original
167            .children()
168            .get_with_index(0)
169            .unwrap()
170            .clone_node_with_deep(true)?
171            .unchecked_into();
172
173        elem.class_list().toggle("snap-drag-image")?;
174        original.append_child(&elem)?;
175        event.data_transfer().into_apierror()?.set_drag_image(
176            &elem,
177            event.offset_x(),
178            event.offset_y(),
179        );
180
181        *self.drag_target.borrow_mut() =
182            Some(DragTargetState::new(self.elem.clone(), original.clone()));
183
184        // Drag image does not register correctly unless we wait.
185        ApiFuture::spawn(async move {
186            request_animation_frame().await;
187            original.remove_child(&elem)?;
188            Ok(())
189        });
190
191        Ok(())
192    }
193
194    // Is the drag/drop state currently in `action`?
195    pub fn is_dragover(&self, drag_target: DragTarget) -> Option<(usize, String)> {
196        match *self.drag_state.borrow() {
197            DragState::DragOverInProgress(
198                DragFrom { ref column, .. },
199                DragOver { target, index },
200            ) if target == drag_target => Some((index, column.clone())),
201            _ => None,
202        }
203    }
204
205    pub fn notify_drop(&self, event: &DragEvent) {
206        event.prevent_default();
207        event.stop_propagation();
208
209        let action = match &*self.drag_state.borrow() {
210            DragState::DragOverInProgress(
211                DragFrom { column, effect },
212                DragOver { target, index },
213            ) => Some((column.to_string(), *target, *effect, *index)),
214            _ => None,
215        };
216
217        self.drag_target.borrow_mut().take();
218        *self.drag_state.borrow_mut() = DragState::NoDrag;
219        if let Some(action) = action {
220            self.drop_received.emit(action);
221        }
222    }
223
224    /// Start the drag/drop action with the name of the column being dragged.
225    pub fn notify_drag_start(&self, column: String, effect: DragEffect) {
226        *self.drag_state.borrow_mut() = DragState::DragInProgress(DragFrom { column, effect });
227        self.register_host_dragend();
228        let emit = self.on_dragstart.borrow().clone();
229        ApiFuture::spawn(async move {
230            request_animation_frame().await;
231            if let Some(cb) = emit {
232                cb.emit(effect);
233            }
234
235            Ok(())
236        });
237    }
238
239    /// End the drag/drop action by resetting the state to default.
240    pub fn notify_drag_end(&self) {
241        if self.drag_state.borrow().is_drag_in_progress() {
242            self.drag_target.borrow_mut().take();
243            *self.drag_state.borrow_mut() = DragState::NoDrag;
244            if let Some(cb) = self.on_dragend.borrow().as_ref() {
245                cb.emit(());
246            }
247        }
248    }
249
250    /// Register a `dragend` listener on the host `<perspective-viewer>`
251    /// element so that drag-end cleanup fires even when Yew re-renders
252    /// remove the original dragged element from the shadow DOM.  The host
253    /// element is outside the virtual DOM and therefore stable.
254    fn register_host_dragend(&self) {
255        // Remove any previously registered listener.
256        if let Some(prev) = self.host_dragend.borrow_mut().take() {
257            let _ = self
258                .elem
259                .remove_event_listener_with_callback("dragend", prev.as_ref().unchecked_ref());
260        }
261
262        let this = self.clone();
263        let closure = Closure::wrap(Box::new(move |_event: DragEvent| {
264            this.notify_drag_end();
265        }) as Box<dyn FnMut(DragEvent)>);
266
267        self.elem
268            .add_event_listener_with_callback("dragend", closure.as_ref().unchecked_ref())
269            .unwrap();
270
271        *self.host_dragend.borrow_mut() = Some(closure);
272    }
273
274    /// Leave the `action` zone.
275    pub fn notify_drag_leave(&self, drag_target: DragTarget) {
276        let reset = match *self.drag_state.borrow() {
277            DragState::DragOverInProgress(
278                DragFrom { ref column, effect },
279                DragOver { target, .. },
280            ) if target == drag_target => Some((column.clone(), effect)),
281            _ => None,
282        };
283
284        if let Some((column, effect)) = reset {
285            self.notify_drag_start(column, effect);
286        }
287    }
288
289    // Enter the `action` zone at `index`, which must be <= the number of children
290    // in the container.
291    pub fn notify_drag_enter(&self, target: DragTarget, index: usize) -> bool {
292        let mut drag_state = self.drag_state.borrow_mut();
293        let should_render = match &*drag_state {
294            DragState::DragOverInProgress(_, drag_to) => {
295                drag_to.target != target || drag_to.index != index
296            },
297            _ => true,
298        };
299
300        *drag_state = match &*drag_state {
301            DragState::DragOverInProgress(drag_from, _) | DragState::DragInProgress(drag_from) => {
302                DragState::DragOverInProgress(drag_from.clone(), DragOver { target, index })
303            },
304            _ => DragState::NoDrag,
305        };
306
307        should_render
308    }
309}
310
311/// Safari does not set `relatedTarget` on `"dragleave"`, which makes it
312/// impossible to determine whether a logical drag leave has happened with just
313/// this event, so use function on `"dragenter"` to capture the `relatedTarget`.
314pub fn dragenter_helper(callback: impl Fn() + 'static, target: NodeRef) -> Callback<DragEvent> {
315    Callback::from({
316        move |event: DragEvent| {
317            maybe_log!({
318                event.stop_propagation();
319                event.prevent_default();
320                if event.related_target().is_none() {
321                    target
322                        .cast::<HtmlElement>()
323                        .into_apierror()?
324                        .dataset()
325                        .set("safaridragleave", "true")?;
326                }
327            });
328
329            callback();
330        }
331    })
332}
333
334/// HTML drag/drop will fire a bubbling `dragleave` event over all children of a
335/// `dragleave`-listened-to element, so we need to filter out the events from
336/// the children elements with this esoteric DOM arcana.
337pub fn dragleave_helper(callback: impl Fn() + 'static, drag_ref: NodeRef) -> Callback<DragEvent> {
338    Callback::from({
339        clone!(drag_ref);
340        move |event: DragEvent| {
341            maybe_log!({
342                event.stop_propagation();
343                event.prevent_default();
344
345                let mut related_target = event
346                    .related_target()
347                    .or_else(|| Some(JsValue::UNDEFINED.unchecked_into::<EventTarget>()))
348                    .and_then(|x| x.dyn_into::<Element>().ok());
349
350                // This is a wild chrome bug. `dragleave` can fire with the `relatedTarget`
351                // property set to an element inside the closed `ShadowRoot` hosted by a
352                // browser-native `<select>` tag, which fails the `.contains()` check
353                // below.  This mystery `ShadowRoot` has a structure that looks like this
354                // (tested in Chrome 92), which we try to detect as best we can below.
355                //
356                // ```html
357                // <div aria-hidden="true">Selected Text Here</siv>
358                // <slot name="user-agent-custom-assign-slot"></slot>
359                // ```
360                //
361                // This is pretty course though, since there is no guarantee this structure
362                // will be maintained in future Chrome versions; the `.expect()` in this
363                // method chain should at least warn us if this regresses.
364                //
365                // Wait - you don't believe me?  Throw a debugger statement inside this
366                // conditional and drag a column over a pivot-mode active columns list.
367                if related_target
368                    .as_ref()
369                    .map(|x| x.has_attribute("aria-hidden"))
370                    .unwrap_or_default()
371                {
372                    related_target = Some(
373                        related_target
374                            .into_apierror()?
375                            .parent_node()
376                            .into_apierror()?
377                            .dyn_ref::<ShadowRoot>()
378                            .ok_or_else(|| JsValue::from("Chrome drag/drop bug detection failed"))?
379                            .host()
380                            .unchecked_into::<Element>(),
381                    )
382                }
383
384                let current_target = drag_ref.cast::<HtmlElement>().unwrap();
385                match related_target {
386                    Some(ref related) => {
387                        // Due to virtual dom these events sometimes fire after
388                        // the node is removed ...
389                        if !current_target.contains(Some(related))
390                            && related.parent_element().is_some()
391                        {
392                            callback();
393                        }
394                    },
395                    None => {
396                        // Safari (OSX and iOS) don't set `relatedTarget`, so we need to
397                        // read a memoized value from the `"dragenter"` event.
398                        let dataset = current_target.dataset();
399                        if dataset.get("safaridragleave").is_some() {
400                            dataset.delete("safaridragleave");
401                        } else {
402                            callback();
403                        }
404                    },
405                };
406            })
407        }
408    })
409}
410
411#[derive(Clone)]
412pub struct DragDropContainer {
413    pub noderef: NodeRef,
414    pub dragenter: Callback<DragEvent>,
415    pub dragleave: Callback<DragEvent>,
416}
417
418impl DragDropContainer {
419    pub fn new<F: Fn() + 'static, G: Fn() + 'static>(ondragenter: F, ondragleave: G) -> Self {
420        let noderef = NodeRef::default();
421        Self {
422            dragenter: dragenter_helper(ondragenter, noderef.clone()),
423            dragleave: dragleave_helper(ondragleave, noderef.clone()),
424            noderef,
425        }
426    }
427}
428
429/// A really, really unfortunate hack that is needed to guarantee that `dragend`
430/// is called even under aggressive DOM mutation after `dragstart` is fired.
431struct DragTargetState {
432    target: HtmlElement,
433    shadow_root: ShadowRoot,
434    alive: Rc<Cell<bool>>,
435    observer: IntersectionObserver,
436}
437
438impl DragTargetState {
439    fn new(host: HtmlElement, target: HtmlElement) -> Self {
440        let shadow_root = host.shadow_root().unwrap();
441        let alive = Rc::new(Cell::new(true));
442        let observer = IntersectionObserver::new(
443            &Closure::<dyn FnMut(js_sys::Array)>::new({
444                clone!(target, shadow_root, alive);
445                move |records: js_sys::Array| {
446                    if !alive.get() {
447                        return;
448                    }
449
450                    for record in records.iter() {
451                        let record: IntersectionObserverEntry = record.unchecked_into();
452                        if !record.is_intersecting() {
453                            shadow_root.append_child(&target).unwrap();
454                            return;
455                        }
456                    }
457                }
458            })
459            .into_js_value()
460            .unchecked_into(),
461        );
462
463        observer.observe(target.as_ref());
464        Self {
465            target,
466            shadow_root,
467            alive,
468            observer,
469        }
470    }
471}
472
473impl Drop for DragTargetState {
474    fn drop(&mut self) {
475        self.alive.set(false);
476        self.observer.unobserve(&self.target);
477        if self.target.is_connected() {
478            let _ = self.shadow_root.remove_child(&self.target);
479        }
480    }
481}