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}