perspective_viewer/components/
status_bar.rs1use 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::custom_events::CustomEvents;
25use crate::js::*;
26use crate::presentation::Presentation;
27use crate::renderer::*;
28use crate::session::*;
29use crate::tasks::*;
30use crate::utils::*;
31use crate::*;
32
33#[derive(Clone, Properties)]
34pub struct StatusBarProps {
35 pub id: String,
37
38 pub on_reset: Callback<bool>,
40
41 #[prop_or_default]
43 pub on_settings: Option<Callback<()>>,
44
45 pub has_table: Option<TableLoadState>,
48 pub is_errored: bool,
49 pub stats: Option<ViewStats>,
50 pub update_count: u32,
52 pub error: Option<TableErrorState>,
53 pub title: Option<String>,
56 pub is_settings_open: bool,
59 pub selected_theme: Option<String>,
60 pub available_themes: PtrEqRc<Vec<String>>,
61 pub is_workspace: bool,
63
64 pub custom_events: CustomEvents,
66 pub session: Session,
67 pub renderer: Renderer,
68 pub presentation: Presentation,
69}
70
71impl PartialEq for StatusBarProps {
72 fn eq(&self, other: &Self) -> bool {
73 self.id == other.id
74 && self.has_table == other.has_table
75 && self.is_errored == other.is_errored
76 && self.stats == other.stats
77 && self.update_count == other.update_count
78 && self.error == other.error
79 && self.title == other.title
80 && self.is_settings_open == other.is_settings_open
81 && self.selected_theme == other.selected_theme
82 && self.available_themes == other.available_themes
83 && self.is_workspace == other.is_workspace
84 }
85}
86
87impl HasCustomEvents for StatusBarProps {
88 fn custom_events(&self) -> &CustomEvents {
89 &self.custom_events
90 }
91}
92
93impl HasPresentation for StatusBarProps {
94 fn presentation(&self) -> &Presentation {
95 &self.presentation
96 }
97}
98
99impl HasRenderer for StatusBarProps {
100 fn renderer(&self) -> &Renderer {
101 &self.renderer
102 }
103}
104
105impl HasSession for StatusBarProps {
106 fn session(&self) -> &Session {
107 &self.session
108 }
109}
110
111impl StateProvider for StatusBarProps {
112 type State = StatusBarProps;
113
114 fn clone_state(&self) -> Self::State {
115 self.clone()
116 }
117}
118
119pub enum StatusBarMsg {
120 Reset(MouseEvent),
121 Export,
122 Copy,
123 CloseExport,
124 CloseCopy,
125 Noop,
126 Eject,
127 SetTheme(String),
128 ResetTheme,
129 PointerEvent(web_sys::PointerEvent),
130 TitleInputEvent,
131 TitleChangeEvent,
132}
133
134pub struct StatusBar {
136 copy_ref: NodeRef,
137 export_ref: NodeRef,
138 input_ref: NodeRef,
139 statusbar_ref: NodeRef,
140 title: Option<String>,
144 copy_target: Option<HtmlElement>,
145 export_target: Option<HtmlElement>,
146}
147
148impl Component for StatusBar {
149 type Message = StatusBarMsg;
150 type Properties = StatusBarProps;
151
152 fn create(ctx: &Context<Self>) -> Self {
153 Self {
154 copy_ref: NodeRef::default(),
155 export_ref: NodeRef::default(),
156 input_ref: NodeRef::default(),
157 statusbar_ref: NodeRef::default(),
158 title: ctx.props().title.clone(),
159 copy_target: None,
160 export_target: None,
161 }
162 }
163
164 fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
165 if ctx.props().title != old_props.title
169 || ctx.props().is_settings_open != old_props.is_settings_open
170 {
171 self.title = ctx.props().title.clone();
172 }
173 true
174 }
175
176 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
177 maybe_log_or_default!(Ok(match msg {
178 StatusBarMsg::Reset(event) => {
179 let all = event.shift_key();
180 ctx.props().on_reset.emit(all);
181 false
182 },
183 StatusBarMsg::ResetTheme => {
184 let presentation = ctx.props().presentation.clone();
185 let session = ctx.props().session.clone();
186 let renderer = ctx.props().renderer.clone();
187 ApiFuture::spawn(async move {
188 presentation.reset_theme().await?;
189 let view = session.get_view().into_apierror()?;
190 renderer.restyle_all(&view).await
191 });
192 true
193 },
194 StatusBarMsg::SetTheme(theme_name) => {
195 let presentation = ctx.props().presentation.clone();
196 let session = ctx.props().session.clone();
197 let renderer = ctx.props().renderer.clone();
198 ApiFuture::spawn(async move {
199 presentation.set_theme_name(Some(&theme_name)).await?;
200 let view = session.get_view().into_apierror()?;
201 renderer.restyle_all(&view).await
202 });
203
204 false
205 },
206 StatusBarMsg::Export => {
207 self.export_target = self.export_ref.cast::<HtmlElement>();
208 true
209 },
210 StatusBarMsg::Copy => {
211 self.copy_target = self.copy_ref.cast::<HtmlElement>();
212 true
213 },
214 StatusBarMsg::CloseExport => {
215 self.export_target = None;
216 true
217 },
218 StatusBarMsg::CloseCopy => {
219 self.copy_target = None;
220 true
221 },
222 StatusBarMsg::Eject => {
223 ctx.props().presentation().on_eject.emit(());
224 false
225 },
226 StatusBarMsg::Noop => {
227 self.title = ctx.props().title.clone();
228 true
229 },
230 StatusBarMsg::TitleInputEvent => {
231 let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
232 let title = elem.value();
233 let title = if title.trim().is_empty() {
234 None
235 } else {
236 Some(title)
237 };
238
239 self.title = title;
240 true
241 },
242 StatusBarMsg::TitleChangeEvent => {
243 let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
244 let title = elem.value();
245 let title = if title.trim().is_empty() {
246 None
247 } else {
248 Some(title)
249 };
250
251 ctx.props().session().set_title(title);
252 false
253 },
254 StatusBarMsg::PointerEvent(event) => {
255 if event.target().map(JsValue::from)
256 == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
257 {
258 ctx.props()
259 .custom_events()
260 .dispatch_event(format!("statusbar-{}", event.type_()).as_str(), &event)?;
261 }
262
263 false
264 },
265 }))
266 }
267
268 fn view(&self, ctx: &Context<Self>) -> Html {
269 let Self::Properties {
270 custom_events,
271 presentation,
272 renderer,
273 session,
274 ..
275 } = ctx.props();
276
277 let has_table = ctx.props().has_table.clone();
278 let is_errored = ctx.props().is_errored;
279 let is_settings_open = ctx.props().is_settings_open;
280 let title = &ctx.props().title;
281
282 let mut is_updating_class_name = classes!();
283 if title.is_some() {
284 is_updating_class_name.push("titled");
285 };
286
287 if !is_settings_open {
288 is_updating_class_name.push(["settings-closed", "titled"]);
289 };
290
291 if !matches!(has_table, Some(TableLoadState::Loaded)) {
292 is_updating_class_name.push("updating");
293 }
294
295 let onblur = ctx.link().callback(|_| StatusBarMsg::Noop);
297 let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
298 let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
299 let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
300 let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
301 let onreset = ctx.link().callback(StatusBarMsg::Reset);
302 let onchange = ctx
303 .link()
304 .callback(|_: Event| StatusBarMsg::TitleChangeEvent);
305
306 let oninput = ctx
307 .link()
308 .callback(|_: InputEvent| StatusBarMsg::TitleInputEvent);
309
310 let is_menu = matches!(has_table, Some(TableLoadState::Loaded))
311 && ctx.props().on_settings.as_ref().is_none();
312 let is_title = is_menu
313 || ctx.props().is_workspace
314 || title.is_some()
315 || is_errored
316 || presentation.is_active(&self.input_ref.cast::<Element>());
317
318 let is_settings = title.is_some()
319 || ctx.props().is_workspace
320 || !matches!(has_table, Some(TableLoadState::Loaded))
321 || is_errored
322 || is_settings_open
323 || presentation.is_active(&self.input_ref.cast::<Element>());
324
325 let on_copy_select = {
326 let props = ctx.props().clone();
327 let link = ctx.link().clone();
328 Callback::from(move |x: ExportFile| {
329 let props = props.clone();
330 let link = link.clone();
331 spawn_local(async move {
332 let mime = x.method.mimetype(x.is_chart);
333 let task = props.export_method_to_blob(x.method);
334 let result = copy_to_clipboard(task, mime).await;
335 crate::maybe_log!({
336 result?;
337 link.send_message(StatusBarMsg::CloseCopy);
338 })
339 })
340 })
341 };
342
343 let on_export_select = {
344 let props = ctx.props().clone();
345 let link = ctx.link().clone();
346 Callback::from(move |x: ExportFile| {
347 if !x.name.is_empty() {
348 clone!(props, link);
349 spawn_local(async move {
350 let val = props.export_method_to_blob(x.method).await.unwrap();
351 let is_chart = props.renderer().is_chart();
352 download(&x.as_filename(is_chart), &val).unwrap();
353 link.send_message(StatusBarMsg::CloseExport);
354 })
355 }
356 })
357 };
358
359 let on_close_copy = ctx.link().callback(|_| StatusBarMsg::CloseCopy);
360 let on_close_export = ctx.link().callback(|_| StatusBarMsg::CloseExport);
361
362 if is_settings {
363 html! {
364 <>
365 <LocalStyle href={css!("status-bar")} />
366 <div
367 ref={&self.statusbar_ref}
368 id={ctx.props().id.clone()}
369 class={is_updating_class_name}
370 {onpointerdown}
371 >
372 <StatusIndicator
373 {custom_events}
374 {renderer}
375 {session}
376 update_count={ctx.props().update_count}
377 error={ctx.props().error.clone()}
378 has_table={ctx.props().has_table.clone()}
379 stats={ctx.props().stats.clone()}
380 />
381 if is_title {
382 <label
383 class="input-sizer"
384 data-value={self.title.clone().unwrap_or_default()}
385 >
386 <input
387 ref={&self.input_ref}
388 placeholder=""
389 value={self.title.clone().unwrap_or_default()}
390 size="10"
391 {onblur}
392 {onchange}
393 {oninput}
394 />
395 <span id="status-bar-placeholder" />
396 </label>
397 }
398 if is_title {
399 <StatusBarRowsCounter stats={ctx.props().stats.clone()} />
400 }
401 <div id="spacer" />
402 if is_menu {
403 <div id="menu-bar" class="section">
404 <ThemeSelector
405 theme={ctx.props().selected_theme.clone()}
406 themes={ctx.props().available_themes.clone()}
407 on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
408 on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
409 />
410 <div id="plugin-settings"><slot name="statusbar-extra" /></div>
411 <span class="hover-target">
412 <span id="reset" class="button" onmousedown={&onreset}>
413 <span class="icon" />
414 <span class="icon-label" />
415 </span>
416 </span>
417 <span
418 ref={&self.export_ref}
419 class="hover-target"
420 onmousedown={onexport}
421 >
422 <span id="export" class="button">
423 <span class="icon" />
424 <span class="icon-label" />
425 </span>
426 </span>
427 <span
428 ref={&self.copy_ref}
429 class="hover-target"
430 onmousedown={oncopy}
431 >
432 <span id="copy" class="button">
433 <span class="icon" />
434 <span class="icon-label" />
435 </span>
436 </span>
437 </div>
438 }
439 if let Some(x) = ctx.props().on_settings.as_ref() {
440 <div
441 id="settings_button"
442 class="noselect"
443 onmousedown={x.reform(|_| ())}
444 >
445 <span class="icon" />
446 </div>
447 <div id="close_button" class="noselect" onmousedown={onclose}>
448 <span class="icon" />
449 </div>
450 }
451 </div>
452 <PortalModal
453 tag_name="perspective-copy-menu"
454 target={self.copy_target.clone()}
455 own_focus=true
456 on_close={on_close_copy}
457 theme={ctx.props().selected_theme.clone().unwrap_or_default()}
458 >
459 <CopyDropDownMenu renderer={renderer.clone()} callback={on_copy_select} />
460 </PortalModal>
461 <PortalModal
462 tag_name="perspective-export-menu"
463 target={self.export_target.clone()}
464 own_focus=true
465 on_close={on_close_export}
466 theme={ctx.props().selected_theme.clone().unwrap_or_default()}
467 >
468 <ExportDropDownMenu
469 renderer={renderer.clone()}
470 session={session.clone()}
471 callback={on_export_select}
472 />
473 </PortalModal>
474 </>
475 }
476 } else if let Some(x) = ctx.props().on_settings.as_ref() {
477 let class = classes!(is_updating_class_name, "floating");
478 html! {
479 <div id={ctx.props().id.clone()} {class}>
480 <div id="settings_button" class="noselect" onmousedown={x.reform(|_| ())}>
481 <span class="icon" />
482 </div>
483 <div id="close_button" class="noselect" onmousedown={&onclose} />
484 </div>
485 }
486 } else {
487 html! {}
488 }
489 }
490}
491
492#[derive(Properties, PartialEq)]
493struct ThemeSelectorProps {
494 pub theme: Option<String>,
495 pub themes: PtrEqRc<Vec<String>>,
496 pub on_reset: Callback<()>,
497 pub on_change: Callback<String>,
498}
499
500#[function_component]
501fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
502 let is_first = props
503 .theme
504 .as_ref()
505 .and_then(|x| props.themes.first().map(|y| y == x))
506 .unwrap_or_default();
507
508 let values = use_memo(props.themes.clone(), |themes| {
509 themes
510 .iter()
511 .cloned()
512 .map(SelectItem::Option)
513 .collect::<Vec<_>>()
514 });
515
516 match &props.theme {
517 None => html! {},
518 Some(selected) => {
519 html! {
520 if values.len() > 1 {
521 <span class="hover-target">
522 <div
523 id="theme_icon"
524 class={if is_first {""} else {"modified"}}
525 tabindex="0"
526 onclick={props.on_reset.reform(|_| ())}
527 />
528 <span id="theme" class="button">
529 <span class="icon" />
530 <Select<String>
531 id="theme_selector"
532 class="invert"
533 {values}
534 selected={selected.to_owned()}
535 on_select={props.on_change.clone()}
536 />
537 </span>
538 </span>
539 }
540 }
541 },
542 }
543}