@perspective-dev/viewer 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cdn/perspective-viewer.js +6 -6
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/perspective-viewer.inline.js +9 -9
  24. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  25. package/dist/esm/perspective-viewer.js +1 -1
  26. package/dist/esm/perspective-viewer.js.map +2 -2
  27. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +2 -0
  28. package/dist/esm/ts-rs/ViewerConfig.d.ts +2 -0
  29. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  30. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  31. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  32. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  33. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  34. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  35. package/dist/wasm/perspective-viewer.d.ts +103 -16
  36. package/dist/wasm/perspective-viewer.js +118 -108
  37. package/dist/wasm/perspective-viewer.wasm +0 -0
  38. package/dist/wasm/perspective-viewer.wasm.d.ts +15 -15
  39. package/package.json +1 -1
  40. package/src/css/column-selector.css +1 -0
  41. package/src/css/column-settings-panel.css +67 -2
  42. package/src/css/column-style.css +4 -3
  43. package/src/css/config-selector.css +22 -1
  44. package/src/css/containers/context-menu.css +12 -6
  45. package/src/css/containers/tabs.css +2 -2
  46. package/src/css/viewer.css +8 -0
  47. package/src/rust/components/column_selector/active_column.rs +21 -27
  48. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  49. package/src/rust/components/column_selector/config_selector.rs +7 -1
  50. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  51. package/src/rust/components/column_selector/inactive_column.rs +8 -1
  52. package/src/rust/components/column_selector.rs +20 -3
  53. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  54. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  55. package/src/rust/components/column_settings_sidebar.rs +163 -16
  56. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  57. package/src/rust/components/containers/sidebar.rs +99 -22
  58. package/src/rust/components/containers/split_panel.rs +6 -0
  59. package/src/rust/components/editable_header.rs +2 -0
  60. package/src/rust/components/main_panel.rs +5 -0
  61. package/src/rust/components/mod.rs +1 -0
  62. package/src/rust/components/panel_menu.rs +7 -0
  63. package/src/rust/components/panel_tab.rs +5 -0
  64. package/src/rust/components/portal.rs +7 -0
  65. package/src/rust/components/viewer/msg.rs +2 -0
  66. package/src/rust/components/viewer/render.rs +12 -0
  67. package/src/rust/components/viewer/settings.rs +33 -1
  68. package/src/rust/components/viewer.rs +2 -0
  69. package/src/rust/components/window_editor.rs +1013 -0
  70. package/src/rust/lib.rs +4 -1
  71. package/src/rust/presentation/column_locator.rs +16 -1
  72. package/src/rust/queries/columns_iter_set.rs +7 -1
  73. package/src/rust/renderer/dispatch.rs +85 -58
  74. package/src/rust/renderer.rs +34 -1
  75. package/src/rust/session/drag_drop_update.rs +18 -0
  76. package/src/rust/session/metadata.rs +66 -9
  77. package/src/rust/session/replace_expression_update.rs +1 -0
  78. package/src/rust/session.rs +7 -5
  79. package/src/rust/tasks/edit_window.rs +155 -0
  80. package/src/rust/tasks/mod.rs +2 -0
  81. package/src/rust/utils/browser/dragdrop.rs +12 -0
  82. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  83. package/src/rust/utils/debounce.rs +76 -27
  84. package/src/svg/pin-icon.svg +5 -0
  85. package/src/themes/icons.css +1 -0
  86. package/src/themes/intl/de.css +7 -0
  87. package/src/themes/intl/es.css +7 -0
  88. package/src/themes/intl/fr.css +7 -0
  89. package/src/themes/intl/ja.css +7 -0
  90. package/src/themes/intl/pt.css +7 -0
  91. package/src/themes/intl/zh.css +7 -0
  92. package/src/themes/intl.css +10 -1
  93. package/src/ts/ts-rs/PanelViewerConfig.ts +2 -1
  94. package/src/ts/ts-rs/ViewerConfig.ts +2 -1
  95. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  96. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  97. package/src/ts/ts-rs/WindowSort.ts +9 -0
  98. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  99. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  100. package/src/ts/ts-rs/Windows.ts +10 -0
  101. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline0.js +0 -0
  102. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline1.js +0 -0
  103. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline2.js +0 -0
  104. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline3.js +0 -0
  105. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline4.js +0 -0
  106. /package/dist/wasm/snippets/{perspective-viewer-daeef4ceb066e6c7 → perspective-viewer-8a8c7d5d696b43ae}/inline5.js +0 -0
@@ -13,12 +13,13 @@ mod attributes_tab;
13
13
 
14
14
  mod save_settings;
15
15
  pub(crate) mod style_tab;
16
+ mod window_tab;
16
17
 
17
18
  use std::rc::Rc;
18
19
 
19
20
  use derivative::Derivative;
20
21
  use itertools::Itertools;
21
- use perspective_client::config::{ColumnType, Expression, ViewConfig};
22
+ use perspective_client::config::{ColumnType, Expression, ViewConfig, WindowSpec};
22
23
  use perspective_client::utils::PerspectiveResultExt;
23
24
  use yew::{Callback, Component, Html, Properties, html, props};
24
25
 
@@ -27,15 +28,19 @@ use self::style_tab::StyleTabProps;
27
28
  use crate::components::column_settings_sidebar::attributes_tab::AttributesTab;
28
29
  use crate::components::column_settings_sidebar::save_settings::SaveSettingsProps;
29
30
  use crate::components::column_settings_sidebar::style_tab::StyleTab;
31
+ use crate::components::column_settings_sidebar::window_tab::{WindowTab, WindowTabProps};
30
32
  use crate::components::containers::sidebar::Sidebar;
31
33
  use crate::components::containers::tab_list::TabList;
32
34
  use crate::components::editable_header::EditableHeaderProps;
33
35
  use crate::components::expression_editor::ExpressionEditorProps;
34
36
  use crate::components::type_icon::TypeIconType;
37
+ use crate::components::window_editor::WindowEditorProps;
35
38
  use crate::presentation::{ColumnLocator, ColumnSettingsTab, Presentation};
36
39
  use crate::renderer::Renderer;
37
40
  use crate::session::{Session, SessionMetadataRc};
38
- use crate::tasks::{delete_expr, save_expr, update_expr};
41
+ use crate::tasks::{
42
+ delete_expr, delete_window, save_expr, save_window, update_expr, update_window,
43
+ };
39
44
  use crate::utils::PtrEqRc;
40
45
 
41
46
  #[derive(Clone, Derivative, Properties)]
@@ -47,9 +52,22 @@ pub struct ColumnSettingsPanelProps {
47
52
  pub width_override: Option<i32>,
48
53
  pub on_select_tab: Callback<ColumnSettingsTab>,
49
54
 
50
- /// Active plugin name threaded as a value prop so that plugin changes
51
- /// trigger re-initialization via `changed()` rather than a PubSub
52
- /// `render_limits_changed` subscription.
55
+ /// Shared trap-door width across the drawer's Style/Attributes/Window
56
+ /// tabs.
57
+ #[prop_or_default]
58
+ pub auto_width: f64,
59
+
60
+ #[prop_or_default]
61
+ pub on_auto_width: Callback<f64>,
62
+
63
+ /// Whether the drawer is pinned into the layout.
64
+ #[prop_or_default]
65
+ pub is_pinned: bool,
66
+
67
+ #[prop_or_default]
68
+ pub on_toggle_pin: Callback<()>,
69
+
70
+ /// Active plugin name.
53
71
  pub plugin_name: Option<String>,
54
72
 
55
73
  /// Session metadata snapshot — threaded from `SessionProps`.
@@ -75,8 +93,10 @@ pub struct ColumnSettingsPanelProps {
75
93
  pub session: Session,
76
94
  }
77
95
 
78
- impl PartialEq for ColumnSettingsPanelProps {
79
- fn eq(&self, other: &Self) -> bool {
96
+ impl ColumnSettingsPanelProps {
97
+ /// Everything EXCEPT the trap-door `auto_width`: the props whose change
98
+ /// invalidates the drafts that `initialize` rebuilds.
99
+ fn identity_eq(&self, other: &Self) -> bool {
80
100
  self.selected_column == other.selected_column
81
101
  && self.selected_tab == other.selected_tab
82
102
  && self.plugin_name == other.plugin_name
@@ -87,12 +107,21 @@ impl PartialEq for ColumnSettingsPanelProps {
87
107
  }
88
108
  }
89
109
 
110
+ impl PartialEq for ColumnSettingsPanelProps {
111
+ fn eq(&self, other: &Self) -> bool {
112
+ self.identity_eq(other)
113
+ && self.auto_width == other.auto_width
114
+ && self.is_pinned == other.is_pinned
115
+ }
116
+ }
117
+
90
118
  #[derive(Debug)]
91
119
  pub enum ColumnSettingsPanelMsg {
92
120
  SetExprValue(Rc<String>),
93
121
  SetExprValid(bool),
94
122
  SetHeaderValue(Option<String>),
95
123
  SetHeaderValid(bool),
124
+ SetWindowValue(Option<WindowSpec>),
96
125
  SetSelectedTab((usize, ColumnSettingsTab)),
97
126
  OnSaveAttributes(()),
98
127
  OnResetAttributes(()),
@@ -117,6 +146,8 @@ pub struct ColumnSettingsPanel {
117
146
  reset_enabled: bool,
118
147
  save_count: u8,
119
148
  save_enabled: bool,
149
+ initial_window_value: Option<WindowSpec>,
150
+ window_value: Option<WindowSpec>,
120
151
  tabs: Vec<ColumnSettingsTab>,
121
152
  }
122
153
 
@@ -138,6 +169,8 @@ impl Component for ColumnSettingsPanel {
138
169
  reset_count: 0,
139
170
  column_name: "".to_owned(),
140
171
  maybe_ty: None,
172
+ initial_window_value: None,
173
+ window_value: None,
141
174
  tabs: vec![],
142
175
  on_input: Callback::default(),
143
176
  on_save: Callback::default(),
@@ -149,12 +182,15 @@ impl Component for ColumnSettingsPanel {
149
182
  }
150
183
 
151
184
  fn changed(&mut self, ctx: &yew::prelude::Context<Self>, old_props: &Self::Properties) -> bool {
152
- if ctx.props() != old_props {
185
+ // Only reached when props are unequal. Re-`initialize` (which wipes
186
+ // in-progress expression/window drafts) only on IDENTITY changes -
187
+ // a trap-door `auto_width` change re-renders the `Sidebar` sizer
188
+ // alone.
189
+ if !ctx.props().identity_eq(old_props) {
153
190
  self.initialize(ctx);
154
- true
155
- } else {
156
- false
157
191
  }
192
+
193
+ true
158
194
  }
159
195
 
160
196
  fn update(&mut self, ctx: &yew::prelude::Context<Self>, msg: Self::Message) -> bool {
@@ -187,6 +223,15 @@ impl Component for ColumnSettingsPanel {
187
223
  self.save_enabled_effect();
188
224
  true
189
225
  },
226
+ ColumnSettingsPanelMsg::SetWindowValue(val) => {
227
+ if self.window_value != val {
228
+ self.window_value = val;
229
+ self.reset_enabled = true;
230
+ true
231
+ } else {
232
+ false
233
+ }
234
+ },
190
235
  ColumnSettingsPanelMsg::SetSelectedTab((_, val)) => {
191
236
  let rerender = ctx.props().selected_tab != Some(val);
192
237
  ctx.props().on_select_tab.emit(val);
@@ -195,19 +240,58 @@ impl Component for ColumnSettingsPanel {
195
240
  ColumnSettingsPanelMsg::OnResetAttributes(()) => {
196
241
  self.header_value.clone_from(&self.initial_header_value);
197
242
  self.expr_value.clone_from(&self.initial_expr_value);
243
+ self.window_value.clone_from(&self.initial_window_value);
198
244
  self.save_enabled = false;
199
245
  self.reset_enabled = false;
200
246
  self.reset_count += 1;
201
247
  true
202
248
  },
203
249
  ColumnSettingsPanelMsg::OnSaveAttributes(()) => {
250
+ if matches!(ctx.props().selected_tab, Some(ColumnSettingsTab::Window)) {
251
+ if let Some(spec) = self.window_value.clone() {
252
+ let name = self
253
+ .header_value
254
+ .clone()
255
+ .unwrap_or_else(|| self.column_name.clone());
256
+ match &ctx.props().selected_column {
257
+ ColumnLocator::Window(old_name) => update_window(
258
+ &ctx.props().session,
259
+ &ctx.props().renderer,
260
+ &ctx.props().presentation,
261
+ old_name.clone(),
262
+ name,
263
+ spec.clone(),
264
+ ),
265
+ _ => {
266
+ if let Err(err) = save_window(
267
+ &ctx.props().session,
268
+ &ctx.props().renderer,
269
+ &ctx.props().presentation,
270
+ name,
271
+ spec.clone(),
272
+ ) {
273
+ tracing::warn!("{}", err);
274
+ }
275
+ },
276
+ }
277
+
278
+ self.initial_window_value = Some(spec);
279
+ self.initial_header_value.clone_from(&self.header_value);
280
+ self.save_enabled = false;
281
+ self.reset_enabled = false;
282
+ self.save_count += 1;
283
+ }
284
+
285
+ return true;
286
+ }
287
+
204
288
  let new_expr = Expression::new(
205
289
  self.header_value.clone().map(|s| s.into()),
206
290
  (*(self.expr_value)).clone().into(),
207
291
  );
208
292
 
209
293
  match &ctx.props().selected_column {
210
- ColumnLocator::Table(_) => {
294
+ ColumnLocator::Table(_) | ColumnLocator::Window(_) => {
211
295
  tracing::error!("Tried to save non-expression column!")
212
296
  },
213
297
  ColumnLocator::Expression(name) => update_expr(
@@ -244,6 +328,13 @@ impl Component for ColumnSettingsPanel {
244
328
  &self.column_name,
245
329
  )
246
330
  .unwrap_or_log();
331
+ } else if ctx.props().selected_column.is_saved_window() {
332
+ delete_window(
333
+ &ctx.props().session,
334
+ &ctx.props().renderer,
335
+ &self.column_name,
336
+ )
337
+ .unwrap_or_log();
247
338
  }
248
339
 
249
340
  ctx.props().on_close.emit(());
@@ -253,15 +344,24 @@ impl Component for ColumnSettingsPanel {
253
344
  }
254
345
 
255
346
  fn view(&self, ctx: &yew::prelude::Context<Self>) -> Html {
347
+ let is_window_tab = matches!(ctx.props().selected_tab, Some(ColumnSettingsTab::Window));
348
+
349
+ let header_placeholder = if is_window_tab {
350
+ Rc::new(self.column_name.clone())
351
+ } else {
352
+ self.expr_value.clone()
353
+ };
354
+
256
355
  let header_props = props!(EditableHeaderProps {
257
356
  initial_value: self.initial_header_value.clone(),
258
- placeholder: self.expr_value.clone(),
357
+ placeholder: header_placeholder,
259
358
  reset_count: self.reset_count,
260
- editable: ctx.props().selected_column.is_expr()
359
+ editable: (ctx.props().selected_column.is_expr()
261
360
  && matches!(
262
361
  ctx.props().selected_tab,
263
362
  Some(ColumnSettingsTab::Attributes)
264
- ),
363
+ ))
364
+ || (ctx.props().selected_column.is_window_editable() && is_window_tab),
265
365
  update_on_input: true,
266
366
  icon_type: self
267
367
  .maybe_ty
@@ -324,7 +424,26 @@ impl Component for ColumnSettingsPanel {
324
424
 
325
425
  let attrs_tab = AttributesTabProps {
326
426
  expr_editor,
327
- save_section,
427
+ save_section: save_section.clone(),
428
+ };
429
+
430
+ let window_changed = self.window_value != self.initial_window_value
431
+ || self.header_value != self.initial_header_value;
432
+ let window_tab = WindowTabProps {
433
+ editor: WindowEditorProps {
434
+ metadata: ctx.props().metadata.clone(),
435
+ initial: self.initial_window_value.clone(),
436
+ on_change: ctx.link().callback(ColumnSettingsPanelMsg::SetWindowValue),
437
+ reset_count: self.reset_count,
438
+ presentation: ctx.props().presentation.clone(),
439
+ session: ctx.props().session.clone(),
440
+ selected_theme: ctx.props().selected_theme.clone(),
441
+ },
442
+ save_section: SaveSettingsProps {
443
+ save_enabled: self.window_value.is_some() && window_changed && self.header_valid,
444
+ show_danger_zone: ctx.props().selected_column.is_saved_window(),
445
+ ..save_section
446
+ },
328
447
  };
329
448
 
330
449
  let style_tab = StyleTabProps {
@@ -342,6 +461,7 @@ impl Component for ColumnSettingsPanel {
342
461
 
343
462
  let tab_children = self.tabs.iter().map(|tab| match tab {
344
463
  ColumnSettingsTab::Attributes => html! { <AttributesTab ..attrs_tab.clone() /> },
464
+ ColumnSettingsTab::Window => html! { <WindowTab ..window_tab.clone() /> },
345
465
  ColumnSettingsTab::Style => html! { <StyleTab ..style_tab.clone() /> },
346
466
  });
347
467
 
@@ -358,6 +478,10 @@ impl Component for ColumnSettingsPanel {
358
478
  on_close={ctx.props().on_close.clone()}
359
479
  id_prefix="column_settings"
360
480
  width_override={ctx.props().width_override}
481
+ auto_width={ctx.props().auto_width}
482
+ on_auto_width={ctx.props().on_auto_width.clone()}
483
+ is_pinned={ctx.props().is_pinned}
484
+ on_toggle_pin={Some(ctx.props().on_toggle_pin.clone())}
361
485
  selected_tab={selected_tab_idx}
362
486
  {header_props}
363
487
  >
@@ -403,6 +527,16 @@ impl ColumnSettingsPanel {
403
527
  .metadata
404
528
  .locator_view_type(&ctx.props().selected_column);
405
529
 
530
+ // Specs are unnamed (the `windows` map key names them, and the
531
+ // editable header owns naming in the UI), so drafts and saved specs
532
+ // compare directly.
533
+ let initial_window_value = ctx
534
+ .props()
535
+ .selected_column
536
+ .name()
537
+ .and_then(|name| ctx.props().view_config.windows.get(name))
538
+ .cloned();
539
+
406
540
  let tabs = {
407
541
  let mut tabs = vec![];
408
542
  let is_new_expr = ctx.props().selected_column.is_new_expr();
@@ -424,6 +558,17 @@ impl ColumnSettingsPanel {
424
558
  tabs.push(ColumnSettingsTab::Attributes);
425
559
  }
426
560
 
561
+ let supports_windows = ctx
562
+ .props()
563
+ .metadata
564
+ .get_features()
565
+ .map(|x| x.has_window_aggregates())
566
+ .unwrap_or_default();
567
+
568
+ if ctx.props().selected_column.is_window_editable() && supports_windows {
569
+ tabs.push(ColumnSettingsTab::Window);
570
+ }
571
+
427
572
  tabs
428
573
  };
429
574
 
@@ -440,6 +585,8 @@ impl ColumnSettingsPanel {
440
585
  header_value: initial_header_value.clone(),
441
586
  initial_header_value,
442
587
  maybe_ty,
588
+ window_value: initial_window_value.clone(),
589
+ initial_window_value,
443
590
  tabs,
444
591
  header_valid: true,
445
592
  on_input,
@@ -52,6 +52,21 @@ where
52
52
 
53
53
  #[prop_or_default]
54
54
  pub allow_duplicates: bool,
55
+
56
+ /// Single-slot mode: the list holds at most one item, a dragover
57
+ /// preview REPLACES the current item rather than inserting beside it,
58
+ /// and the trailing `EmptyColumn` autocomplete renders only while the
59
+ /// slot is empty.
60
+ #[prop_or_default]
61
+ pub single_slot: bool,
62
+
63
+ /// The in-flight drag is INVALID for this list (a parent-defined rule,
64
+ /// e.g. the window editor's Table-columns-only slots): the dragover
65
+ /// preview is suppressed and the invalid-X overlay renders instead,
66
+ /// like a duplicate drag over `group_by`/`split_by`. The parent's drop
67
+ /// handler is still responsible for ignoring the drop itself.
68
+ #[prop_or_default]
69
+ pub is_invalid: bool,
55
70
  }
56
71
 
57
72
  impl<T, U> PartialEq for DragDropListProps<T, U>
@@ -66,6 +81,8 @@ where
66
81
  && self.allow_duplicates == other.allow_duplicates
67
82
  && self.is_dragover == other.is_dragover
68
83
  && self.disabled == other.disabled
84
+ && self.single_slot == other.single_slot
85
+ && self.is_invalid == other.is_invalid
69
86
  }
70
87
  }
71
88
 
@@ -202,7 +219,51 @@ where
202
219
  let invalid_drag: bool;
203
220
  let mut valid_duplicate_drag = false;
204
221
 
205
- let columns_html = {
222
+ let columns_html = if ctx.props().single_slot {
223
+ invalid_drag = ctx.props().is_invalid && ctx.props().is_dragover.is_some();
224
+
225
+ // Dragging the slot's own pill over its own slot is a no-op
226
+ // move - keep showing the pill instead of the drop preview
227
+ // (mirrors the multi-column branch's `is_self_move` handling).
228
+ let is_self_move = ctx
229
+ .props()
230
+ .presentation
231
+ .get_drag_target()
232
+ .map(|x| V::is_self_move(x))
233
+ .unwrap_or_default();
234
+
235
+ let close = ctx.props().parent.callback(|_| V::close(0));
236
+ let dragenter = ctx.props().parent.callback({
237
+ let container_noderef = container_noderef.clone();
238
+ move |event: DragEvent| {
239
+ event.stop_propagation();
240
+ event.prevent_default();
241
+ if event.related_target().is_none()
242
+ && let Some(elem) = container_noderef.cast::<HtmlElement>()
243
+ {
244
+ let _ = elem.dataset().set("safaridragleave", "true");
245
+ }
246
+ V::dragenter(0)
247
+ }
248
+ });
249
+
250
+ if ctx.props().is_dragover.is_some() && !is_self_move && !invalid_drag {
251
+ html! {
252
+ <div class="pivot-column" ondragenter={dragenter}>
253
+ <div class="config-drop" />
254
+ </div>
255
+ }
256
+ } else if let Some(column) = ctx.props().children.iter().next() {
257
+ html! {
258
+ <div class="pivot-column" ondragenter={dragenter}>
259
+ { Html::from(column) }
260
+ <span class="row_close" onmousedown={close} />
261
+ </div>
262
+ }
263
+ } else {
264
+ html! {}
265
+ }
266
+ } else {
206
267
  let mut columns = ctx
207
268
  .props()
208
269
  .children
@@ -211,7 +272,10 @@ where
211
272
  .enumerate()
212
273
  .collect::<Vec<_>>();
213
274
 
214
- invalid_drag = if let Some((x, column)) = &ctx.props().is_dragover {
275
+ invalid_drag = if ctx.props().is_invalid && ctx.props().is_dragover.is_some() {
276
+ // Parent-defined invalidity: no preview, X overlay only.
277
+ true
278
+ } else if let Some((x, column)) = &ctx.props().is_dragover {
215
279
  let index = *x;
216
280
  let is_append = index == columns.len();
217
281
  let is_self_move = ctx
@@ -314,6 +378,12 @@ where
314
378
  .collect::<Html>()
315
379
  };
316
380
 
381
+ let show_empty = if ctx.props().single_slot {
382
+ ctx.props().children.is_empty() && ctx.props().is_dragover.is_none()
383
+ } else {
384
+ ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag)
385
+ };
386
+
317
387
  let column_dropdown = ctx.props().column_dropdown.clone();
318
388
  let exclude = ctx.props().exclude.clone();
319
389
  let on_select = ctx.props().parent.callback(V::create);
@@ -345,7 +415,7 @@ where
345
415
  onmousedown={ctx.props().parent.callback(move |_| V::close(0))}
346
416
  />
347
417
  </div>
348
- } else if ctx.props().is_dragover.is_none() | (!invalid_drag && valid_duplicate_drag) {
418
+ } else if show_empty {
349
419
  <EmptyColumn {column_dropdown} {exclude} {on_select} />
350
420
  } else if invalid_drag {
351
421
  <InvalidColumn />
@@ -11,14 +11,17 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  use perspective_client::clone;
14
- use web_sys::Element;
14
+ use wasm_bindgen::JsCast;
15
+ use wasm_bindgen::prelude::Closure;
16
+ use web_sys::HtmlElement;
15
17
  use yew::{
16
- Callback, Children, Html, Properties, function_component, html, use_effect_with, use_node_ref,
17
- use_state_eq,
18
+ Callback, Children, Html, Properties, function_component, html, use_effect_with, use_mut_ref,
19
+ use_node_ref,
18
20
  };
19
21
 
20
22
  use crate::components::containers::sidebar_close_button::SidebarCloseButton;
21
23
  use crate::components::editable_header::{EditableHeader, EditableHeaderProps};
24
+ use crate::js::{ResizeObserver, ResizeObserverEntry};
22
25
 
23
26
  #[derive(PartialEq, Clone, Properties)]
24
27
  pub struct SidebarProps {
@@ -31,6 +34,28 @@ pub struct SidebarProps {
31
34
  pub width_override: Option<i32>,
32
35
  pub selected_tab: Option<usize>,
33
36
  pub header_props: EditableHeaderProps,
37
+
38
+ /// Trap-door width shared across this sidebar's tabs: the lifted
39
+ /// running max of the widths this component reports through
40
+ /// `on_auto_width`. Held by the parent (ultimately
41
+ /// `PerspectiveViewer`'s geometry state, like the settings panel's
42
+ /// Query/Plugin/Debug trap-door) so it survives tab switches AND
43
+ /// sidebar re-mounts, and clears on divider reset.
44
+ #[prop_or_default]
45
+ pub auto_width: f64,
46
+
47
+ /// Fires with the sidebar's rendered width after each render; the
48
+ /// owner keeps the running max threaded back as `auto_width`.
49
+ #[prop_or_default]
50
+ pub on_auto_width: Callback<f64>,
51
+
52
+ /// Pinned state for the header's pin toggle; the button renders only
53
+ /// when `on_toggle_pin` is provided.
54
+ #[prop_or_default]
55
+ pub is_pinned: bool,
56
+
57
+ #[prop_or_default]
58
+ pub on_toggle_pin: Option<Callback<()>>,
34
59
  }
35
60
 
36
61
  /// Sidebars are designed to live in a
@@ -39,33 +64,85 @@ pub struct SidebarProps {
39
64
  pub fn Sidebar(p: &SidebarProps) -> Html {
40
65
  let id = &p.id_prefix;
41
66
  let noderef = use_node_ref();
42
- let auto_width = use_state_eq(|| 0f64);
43
-
44
- // this gets the last calculated width and ensures that
45
- // the auto-width element is at least that big.
46
- // this ensures the panel grows but does not shrink.
47
- use_effect_with(p.clone(), {
48
- clone!(noderef, auto_width);
49
- move |p| {
50
- if p.width_override.is_none() {
51
- let updated_width = noderef
52
- .cast::<Element>()
53
- .map(|el| el.get_bounding_client_rect().width())
54
- .unwrap_or_default();
55
- let new_auto_width = (*auto_width).max(updated_width);
56
- auto_width.set(new_auto_width);
57
- } else {
58
- auto_width.set(0f64);
67
+
68
+ // The trap-door reports the sidebar's rendered width to its owner via
69
+ // a `ResizeObserver` on the sidebar element, NOT a render effect:
70
+ // width changes are driven by DOM mutations anywhere in the tab
71
+ // subtree (e.g. the window editor staging a long column name into a
72
+ // slot), which need not re-render this component at all. The observer
73
+ // sees every one. `contentRect` (not the border box) is load-bearing:
74
+ // the sizer below is a content child, so ratcheting the border box
75
+ // would feed any sidebar padding back into unbounded growth. A manual
76
+ // divider drag (`width_override`) disables the ratchet until the
77
+ // divider resets.
78
+ let live_props = use_mut_ref(|| (Callback::<f64>::default(), None::<i32>));
79
+ *live_props.borrow_mut() = (p.on_auto_width.clone(), p.width_override);
80
+ use_effect_with((), {
81
+ clone!(noderef, live_props);
82
+ move |_| {
83
+ let closure =
84
+ Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
85
+ let (on_auto_width, width_override) = live_props.borrow().clone();
86
+ if width_override.is_none() {
87
+ for entry in entries.iter() {
88
+ let entry: ResizeObserverEntry = entry.unchecked_into();
89
+ on_auto_width.emit(entry.content_rect().width());
90
+ }
91
+ }
92
+ });
93
+
94
+ let observer = ResizeObserver::new(closure.as_ref().unchecked_ref());
95
+ let elem = noderef.cast::<HtmlElement>();
96
+ if let Some(elem) = &elem {
97
+ observer.observe(elem);
98
+ }
99
+
100
+ move || {
101
+ if let Some(elem) = &elem {
102
+ observer.unobserve(elem);
103
+ }
104
+
105
+ drop(closure);
59
106
  }
60
107
  }
61
108
  });
62
109
 
63
- let width_style = format!("min-width: 200px; width: {}px", *auto_width);
110
+ let auto_width = if p.width_override.is_none() {
111
+ p.auto_width
112
+ } else {
113
+ 0.0
114
+ };
115
+
116
+ let width_style = format!("min-width: 200px; width: {}px", auto_width);
117
+ let pin_button = p.on_toggle_pin.as_ref().map(|cb| {
118
+ let onclick = {
119
+ let cb = cb.clone();
120
+ Callback::from(move |_: web_sys::MouseEvent| cb.emit(()))
121
+ };
122
+
123
+ let mut class = yew::classes!("sidebar_pin_button");
124
+ if p.is_pinned {
125
+ class.push("is-pinned");
126
+ }
127
+
128
+ html! {
129
+ <span
130
+ id={format!("{id}_pin_button")}
131
+ {class}
132
+ title={if p.is_pinned { "Unpin" } else { "Pin" }}
133
+ {onclick}
134
+ />
135
+ }
136
+ });
137
+
64
138
  html! {
65
139
  <>
66
140
  <SidebarCloseButton id={format!("{id}_close_button")} on_close_sidebar={&p.on_close} />
67
141
  <div class="sidebar_column" id={format!("{id}_sidebar")} ref={noderef}>
68
- <div class="sidebar_header"><EditableHeader ..p.header_props.clone() /></div>
142
+ <div class="sidebar_header">
143
+ <EditableHeader ..p.header_props.clone() />
144
+ { pin_button }
145
+ </div>
69
146
  <div class="sidebar_border" id={format!("{id}_border")} />
70
147
  { p.children.iter().collect::<Html>() }
71
148
  <div class="sidebar-auto-width" style={width_style} />
@@ -26,6 +26,10 @@ pub struct SplitPanelProps {
26
26
  #[prop_or_default]
27
27
  pub id: Option<String>,
28
28
 
29
+ /// Extra classes on the root element (ignored under `no_wrap`).
30
+ #[prop_or_default]
31
+ pub class: Classes,
32
+
29
33
  #[prop_or_default]
30
34
  pub orientation: Orientation,
31
35
 
@@ -110,6 +114,7 @@ impl SplitPanelProps {
110
114
  impl PartialEq for SplitPanelProps {
111
115
  fn eq(&self, other: &Self) -> bool {
112
116
  self.id == other.id
117
+ && self.class == other.class
113
118
  && self.children == other.children
114
119
  && self.orientation == other.orientation
115
120
  && self.reverse == other.reverse
@@ -305,6 +310,7 @@ impl Component for SplitPanel {
305
310
  }
306
311
 
307
312
  let mut classes = classes!("split-panel");
313
+ classes.extend(ctx.props().class.clone());
308
314
  if orientation == Orientation::Vertical {
309
315
  classes.push("orient-vertical");
310
316
  }
@@ -119,10 +119,12 @@ impl Component for EditableHeader {
119
119
  }
120
120
  let metadata = &ctx.props().metadata;
121
121
  let expressions = metadata.get_expression_columns();
122
+ let windows = metadata.get_window_columns();
122
123
  let found = metadata
123
124
  .get_table_columns()?
124
125
  .iter()
125
126
  .chain(expressions)
127
+ .chain(windows)
126
128
  .contains(&new_value);
127
129
  Some(!found)
128
130
  })()
@@ -291,6 +291,11 @@ impl Component for MainPanel {
291
291
  .link()
292
292
  .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y));
293
293
  let contextmenu_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
294
+ // Shift+right-click passes through to the native browser menu.
295
+ if event.unchecked_ref::<web_sys::MouseEvent>().shift_key() {
296
+ return;
297
+ }
298
+
294
299
  let path = event.composed_path();
295
300
  let mut panel_id = None;
296
301
  for i in 0..path.length() {
@@ -48,3 +48,4 @@ pub mod style;
48
48
  pub mod style_controls;
49
49
  pub mod type_icon;
50
50
  pub mod viewer;
51
+ pub mod window_editor;