Skip to main content

perspective_viewer/components/
panel_tab.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;
14use std::rc::Rc;
15
16use perspective_js::utils::global;
17use wasm_bindgen::JsCast;
18use wasm_bindgen::prelude::*;
19use web_sys::*;
20use yew::prelude::*;
21
22#[wasm_bindgen(inline_js = r#"
23    export function define_panel_tab(name) {
24        if (!customElements.get(name)) {
25            customElements.define(name, class extends HTMLElement {});
26        }
27    }
28"#)]
29extern "C" {
30    #[wasm_bindgen(js_name = "define_panel_tab")]
31    fn define_panel_tab(name: &str);
32}
33
34/// The tag the tab host is created as (a custom element, so it can own a
35/// ShadowRoot for its contents).
36const TAB_TAG: &str = "perspective-viewer-tab";
37
38thread_local! {
39    /// Whether the `<perspective-viewer-tab>` custom element has been defined
40    /// (once per page; WASM is single-threaded).
41    static ELEMENT_DEFINED: Cell<bool> = const { Cell::new(false) };
42
43    /// The shared, constructed stylesheet adopted into every tab's ShadowRoot.
44    /// Built once from `panel-tab.css`; a constructed `CSSStyleSheet` is cheap
45    /// and safe to adopt into arbitrarily many roots.
46    static TAB_SHEET: CssStyleSheet = {
47        let sheet = CssStyleSheet::new().unwrap();
48        sheet.replace_sync(include_str!("../../css/panel-tab.css"))
49            .unwrap();
50        sheet
51    };
52}
53
54/// Define the `<perspective-viewer-tab>` custom element once. It needs no
55/// lifecycle callbacks — it exists only to own a ShadowRoot into which each
56/// tab's contents are rendered (so the tab's structure CSS is encapsulated in
57/// its `adoptedStyleSheets` rather than injected into `document.head`).
58///
59/// The host stays a *light-DOM* child of the viewer, so the per-panel `theme`
60/// attr is still matched by the document theme rules (`perspective-viewer
61/// [theme="X"]`); the `--psp-*` they set are inherited across the shadow
62/// boundary into the tab's contents.
63fn ensure_custom_element() {
64    ELEMENT_DEFINED.with(|defined| {
65        if !defined.get() {
66            define_panel_tab(TAB_TAG);
67            defined.set(true);
68        }
69    });
70}
71
72/// Adopt the shared tab stylesheet into `shadow_root.adoptedStyleSheets`
73/// (idempotent per root). Mirrors `StyleProvider`'s adopt-by-`Reflect`
74/// approach.
75fn adopt_sheet(shadow_root: &Element) {
76    let sheets = js_sys::Reflect::get(shadow_root.as_ref(), &"adoptedStyleSheets".into())
77        .unwrap()
78        .unchecked_into::<js_sys::Array>();
79
80    TAB_SHEET.with(|sheet| {
81        let sheet_val: &JsValue = sheet.as_ref();
82        if sheets.index_of(sheet_val, 0) < 0 {
83            sheets.push(sheet_val);
84        }
85    });
86}
87
88/// A panel's titlebar tab. The host is a `<perspective-viewer-tab>` custom
89/// element mounted in the viewer's **light DOM** and forwarded into its
90/// `<regular-layout-frame>` titlebar via a `<slot name="tab-{id}" slot="tab">`
91/// (rendered by `MainPanel`); the tab's *contents* live in the host's
92/// **ShadowRoot**, whose `adoptedStyleSheets` carry the structure CSS.
93///
94/// Keeping the host in the light DOM — like the plugin — is what lets
95/// *document* theme CSS (`perspective-viewer [theme="X"]`) reach it, so each
96/// panel's chrome is themed by the native cascade instead of a runtime-inlined
97/// string; the `--psp-*` those rules set are inherited across the shadow
98/// boundary into the contents. The host carries `part="tab"`, which
99/// `<regular-layout-frame>`'s pointer handler treats as a drag handle
100/// (regular-layout@>=0.6.0's custom-tab contract); the title is
101/// `pointer-events:none` so clicks fall through to the host (like the built-in
102/// `<regular-layout-tab>`).
103#[derive(Properties, PartialEq)]
104pub struct PanelTabProps {
105    /// The `<perspective-viewer>` host element; the tab is attached here as a
106    /// light-DOM child (mirrors `renderer::activate`'s plugin mount).
107    pub viewer: HtmlElement,
108
109    /// The panel id. The tab is assigned to `slot="tab-{panel_id}"`.
110    pub panel_id: String,
111
112    /// The panel's title; falls back to the id when `None`.
113    pub title: Option<String>,
114
115    /// This panel's effective theme. Reflected onto the host's `theme`
116    /// attribute so the document theme rules (`perspective-viewer [theme="X"]`)
117    /// theme the tab per-panel via the native cascade.
118    pub theme: Option<String>,
119
120    /// `true` when this is the active panel (toolbar target / selected tab);
121    /// drives the active-tab styling.
122    pub active: bool,
123
124    /// `true` when this panel is *visible* — the front (selected) tab of its
125    /// stack, or a lone panel. Hidden panels are those at an unselected index
126    /// of a tab stack. Independent of `active`: every stack has a visible
127    /// panel, but only one panel in the whole layout is active.
128    pub visible: bool,
129
130    /// `true` when this panel is a master (filter-source) panel — shows the
131    /// broadcast badge to the left of the close button.
132    pub is_master: bool,
133
134    /// `true` when this is the host viewer's ONLY panel (one plugin child).
135    /// Reflected on the host as a `single`/`multi` class — the same
136    /// panel-count CSS hook `Renderer::stamp_active` stamps on the plugin —
137    /// e.g. the caret is hidden on a lone tab (see panel-tab.css).
138    pub single: bool,
139
140    /// `false` for a lone panel (which can't be closed to zero) — hides the
141    /// close button.
142    pub closable: bool,
143
144    /// `true` when the settings sidebar is open. When closed, the tab renders
145    /// an open-settings button *in place of* the close button — the only
146    /// affordance for opening the settings panel (there is deliberately none
147    /// at zero panels).
148    pub is_settings_open: bool,
149
150    /// `false` for a lone panel — suppresses the tab rearrange-drag.
151    /// `<regular-layout-frame>` arms a drag from any `part="tab"` pointerdown,
152    /// but a lone panel has nowhere to drop, so the host `pointerdown` handler
153    /// stops the event before it reaches the frame (see `create`).
154    pub draggable: bool,
155
156    /// Select this panel in the layout (brings its frame forward within a stack
157    /// and activates it). Wired by `MainPanel` to `RegularLayout::select`.
158    pub on_select: Callback<String>,
159
160    /// Remove this panel from the layout. Wired by `MainPanel` to the root
161    /// `ClosePanel` message (which mutates the `Workspace` model first, then
162    /// syncs the slave `regular-layout` — NOT `RegularLayout::remove_panel`
163    /// directly; see the app-initiated-layout-change invariant).
164    pub on_close: Callback<String>,
165
166    /// Open the settings sidebar targeting this panel. Wired by `MainPanel` to
167    /// select this panel in the layout, activate it (so the sidebar binds its
168    /// engines), then toggle the sidebar open.
169    pub on_open_settings: Callback<String>,
170
171    /// Open the panel context menu at `(client_x, client_y)`. Wired here on the
172    /// tab host because the tab's content is a `create_portal` subtree, so its
173    /// events don't reach the frame's main-tree `oncontextmenu` (unlike the
174    /// imperatively-mounted plugin body).
175    pub on_context_menu: Callback<(String, f64, f64)>,
176
177    /// Commit a new title for this panel. Wired by `MainPanel` to this panel's
178    /// own [`Session::set_title`](crate::session::Session::set_title).
179    /// `(panel_id, new_title)`; `None` clears it back to the id fallback.
180    pub on_rename: Callback<(String, Option<String>)>,
181}
182
183/// Max gap (ms) between two tab pointerdowns to count as a double-click.
184const DBLCLICK_MS: f64 = 400.0;
185
186/// Shown in place of the title when a panel has no explicit title (rather than
187/// falling back to the table / plugin name).
188const TITLE_PLACEHOLDER: &str = "untitled";
189
190pub enum PanelTabMsg {
191    /// A pointerdown on the tab host. Selects the panel; and when it's the
192    /// second within [`DBLCLICK_MS`], enters title-edit mode. Carries the
193    /// event `timeStamp` (ms). We synthesize the double-click from pointerdown
194    /// because `<regular-layout-frame>` calls `setPointerCapture` +
195    /// `preventDefault` on the tab's pointerdown (to arm a drag), which
196    /// suppresses the browser's synthesized `click`/`dblclick` — so a native
197    /// `dblclick` listener never fires on the tab.
198    PointerDown(f64),
199    Close,
200    /// The tab's open-settings button (shown while the settings sidebar is
201    /// closed, in the close button's place).
202    OpenSettings,
203    ContextMenu(f64, f64),
204    /// Track the live `<input>` value while editing (drives the auto-sizer).
205    EditInput(String),
206    /// Commit the edited title to the panel's session (blur / Enter).
207    CommitEdit,
208    /// Abandon the edit, restoring the previous title (Escape).
209    CancelEdit,
210}
211
212pub struct PanelTab {
213    host: HtmlElement,
214    /// The host's open ShadowRoot; the tab's contents are portaled here (stored
215    /// as `Element` for `create_portal`, matching `PortalModal`).
216    shadow_root: Element,
217    /// `pointerdown` listener on the host (kept alive); selects this panel and
218    /// synthesizes double-click-to-edit (see [`PanelTabMsg::PointerDown`]).
219    _pointerdown: Closure<dyn FnMut(PointerEvent)>,
220    /// `contextmenu` listener on the host (kept alive); opens the panel menu.
221    _contextmenu: Closure<dyn FnMut(MouseEvent)>,
222    /// Current `draggable` prop, shared into the `pointerdown` closure so a
223    /// prop change (lone ⇄ multi panel) takes effect without re-creating it.
224    draggable: Rc<Cell<bool>>,
225    /// `timeStamp` (ms) of the last host pointerdown, for synthesizing
226    /// double-clicks. `NEG_INFINITY` until the first pointerdown.
227    last_pointerdown: f64,
228    /// `true` while the title is being edited (renders an `<input>`).
229    editing: bool,
230    /// Live edited value; controls the `<input>` and the auto-sizer width.
231    edit_value: String,
232    /// The edit `<input>`, for focusing on edit entry.
233    input_ref: NodeRef,
234    /// Focus + select-all the input on the next render after entering edit
235    /// mode.
236    focus_pending: bool,
237}
238
239impl PanelTab {
240    fn slot_name(panel_id: &str) -> String {
241        format!("tab-{panel_id}")
242    }
243
244    /// Enter title-edit mode, seeding the input with the *real* title (empty
245    /// when `None`, not the id fallback shown in display mode). Ignores
246    /// re-entry so a click while editing doesn't clobber the in-progress
247    /// edit. Returns whether a re-render is needed.
248    fn begin_edit(&mut self, ctx: &Context<Self>) -> bool {
249        if self.editing {
250            return false;
251        }
252
253        self.editing = true;
254        self.focus_pending = true;
255        self.edit_value = ctx.props().title.clone().unwrap_or_default();
256        true
257    }
258
259    /// Reflect `active`/`visible`/`single` onto the host (not a vnode — so
260    /// its class is set imperatively). `visible` marks the front (selected)
261    /// tab of every stack, not just the single active panel; `single`/`multi`
262    /// reflect the host viewer's panel count. The shadow CSS keys off
263    /// `:host(.active)` / `:host(.visible)` / `:host(.single)`.
264    fn sync_class(&self, active: bool, visible: bool, single: bool) {
265        let mut class = Vec::with_capacity(3);
266        if active {
267            class.push("active");
268        }
269
270        if visible {
271            class.push("visible");
272        }
273
274        class.push(if single { "single" } else { "multi" });
275        let _ = self.host.set_attribute("class", &class.join(" "));
276    }
277}
278
279impl Component for PanelTab {
280    type Message = PanelTabMsg;
281    type Properties = PanelTabProps;
282
283    fn create(ctx: &Context<Self>) -> Self {
284        ensure_custom_element();
285
286        let host: HtmlElement = global::document()
287            .create_element(TAB_TAG)
288            .unwrap()
289            .unchecked_into();
290
291        host.set_attribute("part", "tab").unwrap();
292        host.set_attribute("slot", &Self::slot_name(&ctx.props().panel_id))
293            .unwrap();
294
295        let init = ShadowRootInit::new(ShadowRootMode::Open);
296        let shadow_root = host
297            .shadow_root()
298            .unwrap_or_else(|| host.attach_shadow(&init).unwrap())
299            .unchecked_into::<Element>();
300
301        adopt_sheet(&shadow_root);
302        let link = ctx.link().clone();
303        let draggable = Rc::new(Cell::new(ctx.props().draggable));
304        let drag_flag = draggable.clone();
305        let pointerdown = Closure::wrap(Box::new(move |event: PointerEvent| {
306            if !drag_flag.get() {
307                event.stop_propagation();
308                event.prevent_default();
309            }
310
311            let ts = event.unchecked_ref::<Event>().time_stamp();
312            link.send_message(PanelTabMsg::PointerDown(ts));
313        }) as Box<dyn FnMut(PointerEvent)>);
314        let _ = host
315            .add_event_listener_with_callback("pointerdown", pointerdown.as_ref().unchecked_ref());
316
317        let link = ctx.link().clone();
318        let contextmenu = Closure::wrap(Box::new(move |event: MouseEvent| {
319            event.prevent_default();
320            event.stop_propagation();
321            link.send_message(PanelTabMsg::ContextMenu(
322                event.client_x() as f64,
323                event.client_y() as f64,
324            ));
325        }) as Box<dyn FnMut(MouseEvent)>);
326        let _ = host
327            .add_event_listener_with_callback("contextmenu", contextmenu.as_ref().unchecked_ref());
328
329        Self {
330            host,
331            shadow_root,
332            _pointerdown: pointerdown,
333            _contextmenu: contextmenu,
334            draggable,
335            last_pointerdown: f64::NEG_INFINITY,
336            editing: false,
337            edit_value: String::new(),
338            input_ref: NodeRef::default(),
339            focus_pending: false,
340        }
341    }
342
343    fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
344        if ctx.props().panel_id != old.panel_id {
345            let _ = self
346                .host
347                .set_attribute("slot", &Self::slot_name(&ctx.props().panel_id));
348        }
349
350        self.draggable.set(ctx.props().draggable);
351        true
352    }
353
354    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
355        let id = ctx.props().panel_id.clone();
356        match msg {
357            PanelTabMsg::PointerDown(ts) => {
358                ctx.props().on_select.emit(id);
359                let is_double = ts - self.last_pointerdown <= DBLCLICK_MS;
360                self.last_pointerdown = ts;
361                is_double && self.begin_edit(ctx)
362            },
363            PanelTabMsg::Close => {
364                ctx.props().on_close.emit(id);
365                false
366            },
367            PanelTabMsg::OpenSettings => {
368                ctx.props().on_open_settings.emit(id);
369                false
370            },
371            PanelTabMsg::ContextMenu(x, y) => {
372                ctx.props().on_context_menu.emit((id, x, y));
373                false
374            },
375            PanelTabMsg::EditInput(value) => {
376                self.edit_value = value;
377                true
378            },
379            PanelTabMsg::CommitEdit => {
380                if !self.editing {
381                    return false;
382                }
383
384                self.editing = false;
385                let value = self.edit_value.trim();
386                let title = (!value.is_empty()).then(|| value.to_owned());
387                ctx.props().on_rename.emit((id, title));
388                true
389            },
390            PanelTabMsg::CancelEdit => {
391                if !self.editing {
392                    return false;
393                }
394
395                self.editing = false;
396                true
397            },
398        }
399    }
400
401    fn view(&self, ctx: &Context<Self>) -> Html {
402        let on_close = ctx.link().callback(|e: PointerEvent| {
403            e.stop_propagation();
404            PanelTabMsg::Close
405        });
406
407        let on_open_settings = ctx.link().callback(|e: PointerEvent| {
408            e.stop_propagation();
409            PanelTabMsg::OpenSettings
410        });
411
412        let title_html = if self.editing {
413            let oninput = ctx.link().callback(|e: InputEvent| {
414                let value = e
415                    .target()
416                    .map(|t| t.unchecked_into::<HtmlInputElement>().value())
417                    .unwrap_or_default();
418                PanelTabMsg::EditInput(value)
419            });
420            let onblur = ctx.link().callback(|_: FocusEvent| PanelTabMsg::CommitEdit);
421            let onkeydown = ctx
422                .link()
423                .batch_callback(|e: KeyboardEvent| match e.key().as_str() {
424                    "Enter" => vec![PanelTabMsg::CommitEdit],
425                    "Escape" => vec![PanelTabMsg::CancelEdit],
426                    _ => vec![],
427                });
428
429            let onpointerdown = ctx.link().batch_callback(|e: PointerEvent| {
430                e.stop_propagation();
431                Vec::<PanelTabMsg>::new()
432            });
433
434            html! {
435                <label class="psp-tab-title input-sizer" data-value={self.edit_value.clone()}>
436                    <input
437                        ref={self.input_ref.clone()}
438                        value={self.edit_value.clone()}
439                        {oninput}
440                        {onblur}
441                        {onkeydown}
442                        {onpointerdown}
443                    />
444                </label>
445            }
446        } else {
447            // The panel's explicit title, else a placeholder (NOT the table /
448            // plugin name) rendered in the inactive color (see panel-tab.css
449            // `.psp-tab-title.placeholder`).
450            match ctx.props().title.clone().filter(|t| !t.is_empty()) {
451                Some(title) => html! { <span class="psp-tab-title">{ title }</span> },
452                None => html! {
453                    <span class="psp-tab-title placeholder">{ TITLE_PLACEHOLDER }</span>
454                },
455            }
456        };
457
458        let content = html! {
459            <>
460                <span class="psp-tab-caret" />
461                // <span class="psp-tab-grip" />
462                { title_html }
463                if ctx.props().is_master { <span class="psp-tab-master" /> }
464                if !ctx.props().is_settings_open {
465                    <button class="psp-tab-settings" onpointerdown={on_open_settings} />
466                } else if ctx.props().closable {
467                    <button class="psp-tab-close" onpointerdown={on_close} />
468                }
469            </>
470        };
471
472        yew::create_portal(content, self.shadow_root.clone())
473    }
474
475    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
476        self.sync_class(ctx.props().active, ctx.props().visible, ctx.props().single);
477        match &ctx.props().theme {
478            Some(theme) => {
479                let _ = self.host.set_attribute("theme", theme);
480            },
481            None => {
482                let _ = self.host.remove_attribute("theme");
483            },
484        }
485
486        if !self.host.is_connected() {
487            let _ = ctx.props().viewer.append_child(&self.host);
488        }
489
490        // Focus + select-all once, on entry to edit mode.
491        if self.focus_pending {
492            self.focus_pending = false;
493            if let Some(input) = self.input_ref.cast::<HtmlInputElement>() {
494                let _ = input.focus();
495                input.select();
496            }
497        }
498    }
499
500    fn destroy(&mut self, ctx: &Context<Self>) {
501        if self.host.is_connected() {
502            let _ = ctx.props().viewer.remove_child(&self.host);
503        }
504    }
505}