Skip to main content

perspective_viewer/components/
status_bar.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 wasm_bindgen_futures::spawn_local;
14use web_sys::*;
15use yew::prelude::*;
16
17use super::status_indicator::StatusIndicator;
18use super::style::LocalStyle;
19use crate::components::containers::select::*;
20use crate::components::copy_dropdown::CopyDropDownMenu;
21use crate::components::export_dropdown::ExportDropDownMenu;
22use crate::components::portal::PortalModal;
23use crate::components::status_bar_counter::StatusBarRowsCounter;
24use crate::config::*;
25use crate::js::*;
26use crate::presentation::{Presentation, PresentationProps};
27use crate::renderer::*;
28use crate::session::*;
29use crate::tasks::*;
30use crate::utils::*;
31use crate::*;
32
33#[derive(Clone, Properties)]
34pub struct StatusBarProps {
35    // DOM Attribute
36    pub id: String,
37
38    /// Fired when the reset button is clicked.
39    pub on_reset: Callback<bool>,
40
41    /// Fires when the settings button is clicked
42    #[prop_or_default]
43    pub on_settings: Option<Callback<()>>,
44
45    /// Snapshots threaded from root.  Component reads `has_table`, `stats`,
46    /// `error`, `title` from session_props; `selected_theme`,
47    /// `available_themes`, `is_workspace` from presentation_props.
48    pub session_props: SessionProps,
49    pub presentation_props: PresentationProps,
50
51    /// Derived from root: `settings_open && has_table_loaded`.  Used
52    /// here to drive the title-input enabled state and the theme picker
53    /// visibility.
54    pub is_settings_open: bool,
55
56    /// In-flight render counter, threaded to `StatusIndicator`.
57    pub update_count: u32,
58
59    // State
60    pub session: Session,
61    pub renderer: Renderer,
62    pub presentation: Presentation,
63}
64
65impl PartialEq for StatusBarProps {
66    fn eq(&self, other: &Self) -> bool {
67        self.id == other.id
68            && self.session_props == other.session_props
69            && self.presentation_props == other.presentation_props
70            && self.is_settings_open == other.is_settings_open
71            && self.update_count == other.update_count
72    }
73}
74
75pub enum StatusBarMsg {
76    Reset(MouseEvent),
77    Export,
78    Copy,
79    CloseExport,
80    CloseCopy,
81    Noop,
82    Eject,
83    SetTheme(String),
84    ResetTheme,
85    PointerEvent(web_sys::PointerEvent),
86    TitleInputEvent,
87    TitleChangeEvent,
88}
89
90/// A toolbar with buttons, and `Table` & `View` status information.
91pub struct StatusBar {
92    copy_ref: NodeRef,
93    export_ref: NodeRef,
94    input_ref: NodeRef,
95    statusbar_ref: NodeRef,
96    /// Local title tracks the live `<input>` value before the user commits the
97    /// change (blur / Enter).  Reset to the prop value whenever the prop
98    /// changes.
99    title: Option<String>,
100    copy_target: Option<HtmlElement>,
101    export_target: Option<HtmlElement>,
102}
103
104impl Component for StatusBar {
105    type Message = StatusBarMsg;
106    type Properties = StatusBarProps;
107
108    fn create(ctx: &Context<Self>) -> Self {
109        Self {
110            copy_ref: NodeRef::default(),
111            export_ref: NodeRef::default(),
112            input_ref: NodeRef::default(),
113            statusbar_ref: NodeRef::default(),
114            title: ctx.props().session_props.title.clone(),
115            copy_target: None,
116            export_target: None,
117        }
118    }
119
120    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
121        // Keep the local title in sync with the prop whenever the session title
122        // changes externally (e.g. restore() call) or the settings panel opens /
123        // closes (which resets the input element).
124        if ctx.props().session_props.title != old_props.session_props.title
125            || ctx.props().is_settings_open != old_props.is_settings_open
126        {
127            self.title = ctx.props().session_props.title.clone();
128        }
129        true
130    }
131
132    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
133        let r: ApiResult<bool> = (|| {
134            Ok(match msg {
135                StatusBarMsg::Reset(event) => {
136                    let all = event.shift_key();
137                    ctx.props().on_reset.emit(all);
138                    false
139                },
140                StatusBarMsg::ResetTheme => {
141                    update_theme(
142                        &ctx.props().session,
143                        &ctx.props().renderer,
144                        &ctx.props().presentation,
145                        None,
146                    );
147                    true
148                },
149                StatusBarMsg::SetTheme(theme_name) => {
150                    update_theme(
151                        &ctx.props().session,
152                        &ctx.props().renderer,
153                        &ctx.props().presentation,
154                        Some(theme_name),
155                    );
156                    false
157                },
158                StatusBarMsg::Export => {
159                    self.export_target = self.export_ref.cast::<HtmlElement>();
160                    true
161                },
162                StatusBarMsg::Copy => {
163                    self.copy_target = self.copy_ref.cast::<HtmlElement>();
164                    true
165                },
166                StatusBarMsg::CloseExport => {
167                    self.export_target = None;
168                    true
169                },
170                StatusBarMsg::CloseCopy => {
171                    self.copy_target = None;
172                    true
173                },
174                StatusBarMsg::Eject => {
175                    ctx.props().presentation.on_eject.emit(());
176                    false
177                },
178                StatusBarMsg::Noop => {
179                    self.title = ctx.props().session_props.title.clone();
180                    true
181                },
182                StatusBarMsg::TitleInputEvent => {
183                    let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
184                    let title = elem.value();
185                    let title = if title.trim().is_empty() {
186                        None
187                    } else {
188                        Some(title)
189                    };
190
191                    self.title = title;
192                    true
193                },
194                StatusBarMsg::TitleChangeEvent => {
195                    let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
196                    let title = elem.value();
197                    let title = if title.trim().is_empty() {
198                        None
199                    } else {
200                        Some(title)
201                    };
202
203                    ctx.props().session.set_title(title);
204                    false
205                },
206                StatusBarMsg::PointerEvent(event) => {
207                    if event.target().map(JsValue::from)
208                        == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
209                    {
210                        ctx.props().presentation.statusbar_pointer_event.emit(event);
211                    }
212
213                    false
214                },
215            })
216        })();
217        r.unwrap_or_else(|e| {
218            web_sys::console::warn_1(&e.into());
219            Default::default()
220        })
221    }
222
223    fn view(&self, ctx: &Context<Self>) -> Html {
224        let Self::Properties {
225            presentation,
226            renderer,
227            session,
228            ..
229        } = ctx.props();
230
231        let has_table = ctx.props().session_props.has_table.clone();
232        let is_errored = ctx.props().session_props.is_errored();
233        let is_settings_open = ctx.props().is_settings_open;
234        let title = &ctx.props().session_props.title;
235
236        let mut is_updating_class_name = classes!();
237        if title.is_some() {
238            is_updating_class_name.push("titled");
239        };
240
241        if !is_settings_open {
242            is_updating_class_name.push(["settings-closed", "titled"]);
243        };
244
245        if !matches!(has_table, Some(TableLoadState::Loaded)) {
246            is_updating_class_name.push("updating");
247        }
248
249        // TODO Memoizing these would reduce some vdom diffing later on
250        let onblur = ctx.link().callback(|_| StatusBarMsg::Noop);
251        let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
252        let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
253        let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
254        let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
255        let onreset = ctx.link().callback(StatusBarMsg::Reset);
256        let onchange = ctx
257            .link()
258            .callback(|_: Event| StatusBarMsg::TitleChangeEvent);
259
260        let oninput = ctx
261            .link()
262            .callback(|_: InputEvent| StatusBarMsg::TitleInputEvent);
263
264        let is_menu = matches!(has_table, Some(TableLoadState::Loaded))
265            && ctx.props().on_settings.as_ref().is_none();
266        let is_title = is_menu
267            || ctx.props().presentation_props.is_workspace
268            || title.is_some()
269            || is_errored
270            || presentation.is_active(&self.input_ref.cast::<Element>());
271
272        let is_settings = title.is_some()
273            || ctx.props().presentation_props.is_workspace
274            || !matches!(has_table, Some(TableLoadState::Loaded))
275            || is_errored
276            || is_settings_open
277            || presentation.is_active(&self.input_ref.cast::<Element>());
278
279        let on_copy_select = {
280            let props = ctx.props().clone();
281            let link = ctx.link().clone();
282            Callback::from(move |x: ExportFile| {
283                let props = props.clone();
284                let link = link.clone();
285                spawn_local(async move {
286                    let mime = x.method.mimetype(x.is_chart);
287                    let task = export_method_to_blob(
288                        &props.session,
289                        &props.renderer,
290                        &props.presentation,
291                        x.method,
292                    );
293                    let result = copy_to_clipboard(task, mime).await;
294                    let r = (|| -> ApiResult<()> {
295                        result?;
296                        link.send_message(StatusBarMsg::CloseCopy);
297                        Ok(())
298                    })();
299                    if let Err(e) = r {
300                        web_sys::console::warn_1(&e.into());
301                    }
302                })
303            })
304        };
305
306        let on_export_select = {
307            let props = ctx.props().clone();
308            let link = ctx.link().clone();
309            Callback::from(move |x: ExportFile| {
310                if !x.name.is_empty() {
311                    clone!(props, link);
312                    spawn_local(async move {
313                        let val = export_method_to_blob(
314                            &props.session,
315                            &props.renderer,
316                            &props.presentation,
317                            x.method,
318                        )
319                        .await
320                        .unwrap();
321                        let is_chart = props.renderer.is_chart();
322                        download(&x.as_filename(is_chart), &val).unwrap();
323                        link.send_message(StatusBarMsg::CloseExport);
324                    })
325                }
326            })
327        };
328
329        let on_close_copy = ctx.link().callback(|_| StatusBarMsg::CloseCopy);
330        let on_close_export = ctx.link().callback(|_| StatusBarMsg::CloseExport);
331
332        if is_settings {
333            html! {
334                <>
335                    <LocalStyle href={css!("status-bar")} />
336                    <div
337                        ref={&self.statusbar_ref}
338                        id={ctx.props().id.clone()}
339                        class={is_updating_class_name}
340                        {onpointerdown}
341                    >
342                        <StatusIndicator
343                            {renderer}
344                            {session}
345                            update_count={ctx.props().update_count}
346                            session_props={ctx.props().session_props.clone()}
347                        />
348                        if is_title {
349                            <label
350                                class="input-sizer"
351                                data-value={self.title.clone().unwrap_or_default()}
352                            >
353                                <input
354                                    ref={&self.input_ref}
355                                    placeholder=""
356                                    value={self.title.clone().unwrap_or_default()}
357                                    size="10"
358                                    {onblur}
359                                    {onchange}
360                                    {oninput}
361                                />
362                                <span id="status-bar-placeholder" />
363                            </label>
364                        }
365                        if is_title {
366                            <StatusBarRowsCounter stats={ctx.props().session_props.stats.clone()} />
367                        }
368                        <div id="spacer" />
369                        if is_menu {
370                            <div id="menu-bar" class="section">
371                                <ThemeSelector
372                                    theme={ctx.props().presentation_props.selected_theme.clone()}
373                                    themes={ctx.props().presentation_props.available_themes.clone()}
374                                    on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
375                                    on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
376                                />
377                                <div id="plugin-settings"><slot name="statusbar-extra" /></div>
378                                <span class="hover-target">
379                                    <span id="reset" class="button" onmousedown={&onreset}>
380                                        <span class="icon" />
381                                        <span class="icon-label" />
382                                    </span>
383                                </span>
384                                <span
385                                    ref={&self.export_ref}
386                                    class="hover-target"
387                                    onmousedown={onexport}
388                                >
389                                    <span id="export" class="button">
390                                        <span class="icon" />
391                                        <span class="icon-label" />
392                                    </span>
393                                </span>
394                                <span
395                                    ref={&self.copy_ref}
396                                    class="hover-target"
397                                    onmousedown={oncopy}
398                                >
399                                    <span id="copy" class="button">
400                                        <span class="icon" />
401                                        <span class="icon-label" />
402                                    </span>
403                                </span>
404                            </div>
405                        }
406                        if let Some(x) = ctx.props().on_settings.as_ref() {
407                            <div
408                                id="settings_button"
409                                class="noselect"
410                                onmousedown={x.reform(|_| ())}
411                            >
412                                <span class="icon" />
413                            </div>
414                            <div id="close_button" class="noselect" onmousedown={onclose}>
415                                <span class="icon" />
416                            </div>
417                        }
418                    </div>
419                    <PortalModal
420                        tag_name="perspective-copy-menu"
421                        target={self.copy_target.clone()}
422                        own_focus=true
423                        on_close={on_close_copy}
424                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
425                    >
426                        <CopyDropDownMenu renderer={renderer.clone()} callback={on_copy_select} />
427                    </PortalModal>
428                    <PortalModal
429                        tag_name="perspective-export-menu"
430                        target={self.export_target.clone()}
431                        own_focus=true
432                        on_close={on_close_export}
433                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
434                    >
435                        <ExportDropDownMenu
436                            renderer={renderer.clone()}
437                            session={session.clone()}
438                            callback={on_export_select}
439                        />
440                    </PortalModal>
441                </>
442            }
443        } else if let Some(x) = ctx.props().on_settings.as_ref() {
444            let class = classes!(is_updating_class_name, "floating");
445            html! {
446                <div id={ctx.props().id.clone()} {class}>
447                    <div id="settings_button" class="noselect" onmousedown={x.reform(|_| ())}>
448                        <span class="icon" />
449                    </div>
450                    <div id="close_button" class="noselect" onmousedown={&onclose} />
451                </div>
452            }
453        } else {
454            html! {}
455        }
456    }
457}
458
459#[derive(Properties, PartialEq)]
460struct ThemeSelectorProps {
461    pub theme: Option<String>,
462    pub themes: PtrEqRc<Vec<String>>,
463    pub on_reset: Callback<()>,
464    pub on_change: Callback<String>,
465}
466
467#[function_component]
468fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
469    let is_first = props
470        .theme
471        .as_ref()
472        .and_then(|x| props.themes.first().map(|y| y == x))
473        .unwrap_or_default();
474
475    let values = use_memo(props.themes.clone(), |themes| {
476        themes
477            .iter()
478            .cloned()
479            .map(SelectItem::Option)
480            .collect::<Vec<_>>()
481    });
482
483    match &props.theme {
484        None => html! {},
485        Some(selected) => {
486            html! {
487                if values.len() > 1 {
488                    <span class="hover-target">
489                        <div
490                            id="theme_icon"
491                            class={if is_first {""} else {"modified"}}
492                            tabindex="0"
493                            onclick={props.on_reset.reform(|_| ())}
494                        />
495                        <span id="theme" class="button">
496                            <span class="icon" />
497                            <Select<String>
498                                id="theme_selector"
499                                class="invert"
500                                {values}
501                                selected={selected.to_owned()}
502                                on_select={props.on_change.clone()}
503                            />
504                        </span>
505                    </span>
506                }
507            }
508        },
509    }
510}