Skip to main content

perspective_viewer/components/
dragdrop_list.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::collections::HashSet;
14use std::marker::PhantomData;
15
16use derivative::Derivative;
17use perspective_client::proto::ColumnType;
18use web_sys::*;
19use yew::html::Scope;
20use yew::prelude::*;
21
22use crate::components::column_dropdown::ColumnDropDownElement;
23use crate::components::column_selector::{EmptyColumn, InPlaceColumn, InvalidColumn};
24use crate::components::type_icon::TypeIcon;
25use crate::presentation::{DragDropContainer, Presentation};
26use crate::ui::intl_slug;
27use crate::utils::DragTarget;
28
29#[derive(Properties, Derivative)]
30#[derivative(Clone(bound = ""))]
31pub struct DragDropListProps<T, U>
32where
33    T: Component,
34    U: Component,
35    <U as Component>::Properties: DragDropListItemProps,
36{
37    pub parent: Scope<T>,
38
39    pub presentation: Presentation,
40    pub name: &'static str,
41    pub column_dropdown: ColumnDropDownElement,
42    pub exclude: HashSet<String>,
43    pub children: ChildrenWithProps<U>,
44
45    #[prop_or_default]
46    pub disabled: bool,
47
48    #[prop_or_default]
49    pub is_dragover: Option<(
50        usize,
51        <<U as Component>::Properties as DragDropListItemProps>::Item,
52    )>,
53
54    #[prop_or_default]
55    pub allow_duplicates: bool,
56
57    /// Single-slot mode: the list holds at most one item, a dragover
58    /// preview REPLACES the current item rather than inserting beside it,
59    /// and the trailing `EmptyColumn` autocomplete renders only while the
60    /// slot is empty.
61    #[prop_or_default]
62    pub single_slot: bool,
63
64    /// The plugin-declared visual role this slot fills, e.g. `"X Axis"`
65    /// for a `Y Line`'s `group_by` (see `PluginStaticConfig`). Rendered
66    /// as the slot's label THROUGH the intl indirection: the role is a
67    /// key, not display text, so `--psp-label--role--x-axis--content`
68    /// supplies the words and a language variant can override them. A
69    /// role with no such label falls back to the declared English, and
70    /// no role at all falls back to the slot's own generic label.
71    #[prop_or_default]
72    pub role_label: Option<AttrValue>,
73
74    /// The in-flight drag is INVALID for this list (a parent-defined rule,
75    /// e.g. the window editor's Table-columns-only slots): the dragover
76    /// preview is suppressed and the invalid-X overlay renders instead,
77    /// like a duplicate drag over `group_by`/`split_by`. The parent's drop
78    /// handler is still responsible for ignoring the drop itself.
79    #[prop_or_default]
80    pub is_invalid: bool,
81}
82
83impl<T, U> PartialEq for DragDropListProps<T, U>
84where
85    T: Component,
86    U: Component,
87    <U as Component>::Properties: DragDropListItemProps,
88{
89    fn eq(&self, other: &Self) -> bool {
90        self.name == other.name
91            && self.children == other.children
92            && self.allow_duplicates == other.allow_duplicates
93            && self.is_dragover == other.is_dragover
94            && self.disabled == other.disabled
95            && self.single_slot == other.single_slot
96            && self.is_invalid == other.is_invalid
97            && self.role_label == other.role_label
98    }
99}
100
101pub enum DragDropListMsg {
102    Freeze(bool),
103}
104
105/// A sub-selector for a list-like component of a `JsViewConfig`, such as
106/// `filters` and `sort`.  
107///
108/// `DragDropList` is parameterized by two `Component`
109/// types, the parent component `T` and the inner item compnent `U`, which must
110/// additionally implement `DragDropListItemProps` trait on its own `Properties`
111/// associated type.
112///
113/// Before you ask:  yes, `frozen_size` needs to be a float64 since `flex`
114/// containers can have fractional dimensions.
115pub struct DragDropList<T, U, V>
116where
117    T: Component,
118    U: Component,
119    <U as Component>::Properties: DragDropListItemProps,
120    V: DragContext<T::Message> + 'static,
121{
122    parent_type: PhantomData<T>,
123    item_type: PhantomData<U>,
124    draggable_type: PhantomData<V>,
125    elem: NodeRef,
126    frozen_size: Option<f64>,
127}
128
129impl<T, U, V> Component for DragDropList<T, U, V>
130where
131    T: Component,
132    U: Component,
133    <U as Component>::Properties: DragDropListItemProps,
134    V: DragContext<T::Message> + 'static,
135{
136    type Message = DragDropListMsg;
137    type Properties = DragDropListProps<T, U>;
138
139    fn create(_ctx: &Context<Self>) -> Self {
140        Self {
141            parent_type: PhantomData,
142            item_type: PhantomData,
143            draggable_type: PhantomData,
144            elem: NodeRef::default(),
145            frozen_size: None,
146        }
147    }
148
149    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
150        true
151    }
152
153    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
154        match msg {
155            // When a dragover occurs and a new Column is inserted into the selector,
156            // the geometry of the selector may expand and cause a parent reflow,
157            // which annoyingly changes the drag status and glitchiness occurs.
158            // By using `Freeze` when a dragenter occurs, the element's width will be
159            // frozen until `drop` or `dragleave`.
160            DragDropListMsg::Freeze(freeze) => {
161                if freeze && self.frozen_size.is_none() {
162                    let elem = self.elem.cast::<HtmlElement>().unwrap();
163                    self.frozen_size = Some({
164                        // `offset_width` and family are `i32`, but Chrome _really_
165                        // uses fractional pixel widths for these which can only be
166                        // recovered by parsing the generated stylesheet ...
167                        let txt = window()
168                            .unwrap()
169                            .get_computed_style(&elem)
170                            .unwrap()
171                            .unwrap()
172                            .get_property_value("width")
173                            .unwrap();
174
175                        // Strip "px" suffix, e.g. "24.876px".
176                        let px = &txt[..txt.len() - 2];
177                        px.parse::<f64>().unwrap()
178                    });
179                    true
180                } else if !freeze {
181                    // Don't render because the invoker will do so through `dragdrop`.
182                    self.frozen_size = None;
183                    false
184                } else {
185                    false
186                }
187            },
188        }
189    }
190
191    fn view(&self, ctx: &Context<Self>) -> Html {
192        let dragover = Callback::from(|_event: DragEvent| _event.prevent_default());
193
194        // On dragleave, signal the parent but no need to redraw as parent will call
195        // `change()` when resetting props.
196        let drag_container = DragDropContainer::new(
197            {
198                let total = ctx.props().children.len();
199                let parent = ctx.props().parent.clone();
200                let link = ctx.link().clone();
201                move || {
202                    link.send_message(DragDropListMsg::Freeze(true));
203                    parent.send_message(V::dragenter(total))
204                }
205            },
206            {
207                let parent = ctx.props().parent.clone();
208                let link = ctx.link().clone();
209                move || {
210                    link.send_message(DragDropListMsg::Freeze(false));
211                    parent.send_message(V::dragleave())
212                }
213            },
214        );
215
216        let drop = Callback::from({
217            let presentation = ctx.props().presentation.clone();
218            let link = ctx.link().clone();
219            move |event| {
220                link.send_message(DragDropListMsg::Freeze(false));
221                presentation.notify_drop(&event);
222            }
223        });
224
225        // Held by per-row `ondragenter` closures below so they can re-arm
226        // the `safaridragleave` flag on the container element when the
227        // row stops dragenter from bubbling. See the comment inside the
228        // closure for why this matters.
229        let container_noderef = drag_container.noderef.clone();
230
231        let invalid_drag: bool;
232        let mut valid_duplicate_drag = false;
233
234        let columns_html = if ctx.props().single_slot {
235            invalid_drag = ctx.props().is_invalid && ctx.props().is_dragover.is_some();
236
237            // Dragging the slot's own pill over its own slot is a no-op
238            // move - keep showing the pill instead of the drop preview
239            // (mirrors the multi-column branch's `is_self_move` handling).
240            let is_self_move = ctx
241                .props()
242                .presentation
243                .get_drag_target()
244                .map(|x| V::is_self_move(x))
245                .unwrap_or_default();
246
247            let close = ctx.props().parent.callback(|_| V::close(0));
248            let dragenter = ctx.props().parent.callback({
249                let container_noderef = container_noderef.clone();
250                move |event: DragEvent| {
251                    event.stop_propagation();
252                    event.prevent_default();
253                    if event.related_target().is_none()
254                        && let Some(elem) = container_noderef.cast::<HtmlElement>()
255                    {
256                        let _ = elem.dataset().set("safaridragleave", "true");
257                    }
258                    V::dragenter(0)
259                }
260            });
261
262            if ctx.props().is_dragover.is_some() && !is_self_move && !invalid_drag {
263                html! {
264                    <div class="pivot-column" ondragenter={dragenter}>
265                        <div class="config-drop" />
266                    </div>
267                }
268            } else if let Some(column) = ctx.props().children.iter().next() {
269                html! {
270                    <div class="pivot-column" ondragenter={dragenter}>
271                        { Html::from(column) }
272                        <span class="row_close" onmousedown={close} />
273                    </div>
274                }
275            } else {
276                html! {}
277            }
278        } else {
279            let mut columns = ctx
280                .props()
281                .children
282                .iter()
283                .map(|x| (true, Some(x)))
284                .enumerate()
285                .collect::<Vec<_>>();
286
287            invalid_drag = if ctx.props().is_invalid && ctx.props().is_dragover.is_some() {
288                // Parent-defined invalidity: no preview, X overlay only.
289                true
290            } else if let Some((x, column)) = &ctx.props().is_dragover {
291                let index = *x;
292                let is_append = index == columns.len();
293                let is_self_move = ctx
294                    .props()
295                    .presentation
296                    .get_drag_target()
297                    .map(|x| V::is_self_move(x))
298                    .unwrap_or_default();
299
300                let is_duplicate = columns
301                    .iter()
302                    .position(|x| x.1.1.as_ref().unwrap().props.get_item() == *column);
303
304                valid_duplicate_drag = is_duplicate.is_some() && !ctx.props().allow_duplicates;
305                if let Some(duplicate) = is_duplicate
306                    && !is_append
307                    && (!ctx.props().allow_duplicates || is_self_move)
308                {
309                    columns.remove(duplicate);
310                }
311
312                // If inserting into the middle of the list, use
313                // the length of the existing element to prevent
314                // jitter as the underlying dragover zone moves.
315                if index < columns.len() {
316                    columns.insert(index, (usize::MAX, (false, None)));
317                    false
318                } else if (!is_append && !ctx.props().allow_duplicates)
319                    || ((!is_append || !is_self_move)
320                        && (is_duplicate.is_none() || ctx.props().allow_duplicates))
321                {
322                    columns.push((usize::MAX, (false, None)));
323                    false
324                } else {
325                    true
326                }
327            } else {
328                false
329            };
330
331            columns
332                .into_iter()
333                .enumerate()
334                .map(|(idx, column)| {
335                    let close = ctx.props().parent.callback(move |_| V::close(idx));
336                    let dragenter = ctx.props().parent.callback({
337                        let link = ctx.link().clone();
338                        let container_noderef = container_noderef.clone();
339                        move |event: DragEvent| {
340                            event.stop_propagation();
341                            event.prevent_default();
342                            // Safari: `relatedTarget` is always null on
343                            // dragleave, so `dragleave_helper` uses a
344                            // `data-safaridragleave` flag set by the
345                            // container's own dragenter to distinguish
346                            // child-crossing leaves (consume the flag)
347                            // from real leaves (no flag → fire callback).
348                            // The `stop_propagation` above blocks the
349                            // container's dragenter, so the flag would
350                            // never be re-armed after the first consume —
351                            // any further internal boundary crossing
352                            // would demote the state out of
353                            // `DragOverInProgress` and the next drop
354                            // would be silently rejected. Set the flag
355                            // here so each row-targeted dragenter
356                            // refills the pool.
357                            if event.related_target().is_none()
358                                && let Some(elem) = container_noderef.cast::<HtmlElement>()
359                            {
360                                let _ = elem.dataset().set("safaridragleave", "true");
361                            }
362                            link.send_message(DragDropListMsg::Freeze(true));
363                            V::dragenter(idx)
364                        }
365                    });
366
367                    if let (key, (true, Some(column))) = column {
368                        html! {
369                            <div {key} class="pivot-column" ondragenter={dragenter}>
370                                { Html::from(column) }
371                                <span class="row_close" onmousedown={close} />
372                            </div>
373                        }
374                    } else if let (key, (_, Some(column))) = column {
375                        html! {
376                            <div {key} class="pivot-column" ondragenter={dragenter}>
377                                { Html::from(column) }
378                                <span class="row_close" style="opacity: 0.3" />
379                            </div>
380                        }
381                    } else {
382                        let (key, _) = column;
383                        html! {
384                            <div {key} class="pivot-column" ondragenter={dragenter}>
385                                <div class="config-drop" />
386                            </div>
387                        }
388                    }
389                })
390                .collect::<Html>()
391        };
392
393        let show_empty = if ctx.props().single_slot {
394            ctx.props().children.is_empty() && ctx.props().is_dragover.is_none()
395        } else {
396            ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag)
397        };
398
399        let column_dropdown = ctx.props().column_dropdown.clone();
400        let exclude = ctx.props().exclude.clone();
401        let on_select = ctx.props().parent.callback(V::create);
402        let class = classes!("rrow");
403        let is_enabled = true;
404
405        // The role is data from the plugin, so the var NAME is built
406        // here while the var VALUE stays in the stylesheets - custom
407        // properties inherit, so the label's `:before` picks it up.
408        let role_style = ctx.props().role_label.as_ref().map(|role| {
409            let slug = intl_slug(role);
410
411            // The second var is a PRESENCE FLAG: CSS cannot ask whether a
412            // custom property is set, so the slot's stylesheet reads it as
413            // the secondary label's `display` and gets `none` by fallback
414            // when no role was declared.
415            format!(
416                "--psp-label--pivot--content: var(--psp-label--role--{slug}--content, \
417                 \"{role}\"); --psp-label--pivot-secondary--display: inline-block"
418            )
419        });
420
421        html! {
422            <div ref={&self.elem} {class}>
423                <div
424                    id={ctx.props().name}
425                    style={role_style}
426                    ondragover={is_enabled.then_some(dragover)}
427                    ondragenter={is_enabled.then_some(drag_container.dragenter)}
428                    ondragleave={is_enabled.then_some(drag_container.dragleave)}
429                    ref={drag_container.noderef}
430                    ondrop={is_enabled.then_some(drop)}
431                >
432                    <div class="psp-text-field">
433                        <ul class="psp-text-field__input" for={ctx.props().name}>
434                            { columns_html }
435                            if ctx.props().disabled && ctx.props().is_dragover.is_none() {
436                                <div class="pivot-column">
437                                    <div class="pivot-column-border pivot-column-total">
438                                        <span class="drag-handle icon" />
439                                        <TypeIcon ty={ColumnType::Integer} />
440                                        <span class="column_name">{ "TOTAL" }</span>
441                                    </div>
442                                    <span
443                                        class="toggle-mode is_column_active"
444                                        onmousedown={ctx.props().parent.callback(move |_| V::close(0))}
445                                    />
446                                </div>
447                            } else if show_empty {
448                                <EmptyColumn {column_dropdown} {exclude} {on_select} />
449                            } else if invalid_drag {
450                                <InvalidColumn />
451                            }
452                        </ul>
453                        <label class="pivot-selector-label" for={ctx.props().name} />
454                    </div>
455                </div>
456            </div>
457        }
458    }
459}
460
461/// Must be implemented by `Properties` of children of `DragDropList`, returning
462/// the value a DragDropItem represents.
463pub trait DragDropListItemProps: Properties {
464    type Item: Clone + PartialEq;
465    fn get_item(&self) -> Self::Item;
466}
467
468pub trait DragContext<T> {
469    fn close(index: usize) -> T;
470    fn dragleave() -> T;
471    fn dragenter(index: usize) -> T;
472    fn create(col: InPlaceColumn) -> T;
473    fn is_self_move(effect: DragTarget) -> bool;
474}