@perspective-dev/viewer 5.5.1 → 5.6.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 (194) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  43. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  44. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  45. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  46. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  47. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  48. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  49. package/dist/wasm/perspective-viewer.d.ts +115 -35
  50. package/dist/wasm/perspective-viewer.js +314 -190
  51. package/dist/wasm/perspective-viewer.wasm +0 -0
  52. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  53. package/package.json +31 -18
  54. package/src/css/column-selector.css +205 -50
  55. package/src/css/ui/containers/context-menu.css +12 -1
  56. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  57. package/src/css/ui/containers/scroll-panel.css +4 -1
  58. package/src/rust/agent/client/protocol.rs +0 -89
  59. package/src/rust/agent/markdown/mod.rs +0 -238
  60. package/src/rust/agent/preamble.md +1 -0
  61. package/src/rust/agent/tools.rs +2 -16
  62. package/src/rust/components/column_selector/active_column.rs +15 -5
  63. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  64. package/src/rust/components/column_selector/config_selector.rs +34 -6
  65. package/src/rust/components/column_selector/derived_control.rs +229 -0
  66. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  67. package/src/rust/components/column_selector/filter_column.rs +0 -2
  68. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  69. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  70. package/src/rust/components/column_selector/sort_column.rs +4 -3
  71. package/src/rust/components/column_selector.rs +21 -1
  72. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  73. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  74. package/src/rust/components/column_settings_sidebar.rs +25 -19
  75. package/src/rust/components/debug_panel.rs +38 -29
  76. package/src/rust/components/dragdrop_list.rs +8 -9
  77. package/src/rust/components/main_panel/msg.rs +6 -4
  78. package/src/rust/components/main_panel/reconcile.rs +6 -1
  79. package/src/rust/components/main_panel/render.rs +70 -51
  80. package/src/rust/components/main_panel/update.rs +39 -1
  81. package/src/rust/components/main_panel.rs +97 -59
  82. package/src/rust/components/new_panel_menu.rs +69 -1
  83. package/src/rust/components/panel_menu.rs +63 -12
  84. package/src/rust/components/plugin_tab.rs +3 -2
  85. package/src/rust/components/render_warning.rs +2 -1
  86. package/src/rust/components/settings_panel.rs +4 -4
  87. package/src/rust/components/status_bar.rs +74 -24
  88. package/src/rust/components/status_indicator.rs +8 -1
  89. package/src/rust/components/viewer/filters.rs +13 -1
  90. package/src/rust/components/viewer/msg.rs +4 -0
  91. package/src/rust/components/viewer/panels.rs +41 -1
  92. package/src/rust/components/viewer/render.rs +1 -0
  93. package/src/rust/components/viewer/settings.rs +0 -22
  94. package/src/rust/components/viewer/snapshots.rs +2 -2
  95. package/src/rust/components/viewer/wiring.rs +14 -0
  96. package/src/rust/components/viewer.rs +45 -25
  97. package/src/rust/components/window_editor.rs +5 -4
  98. package/src/rust/config/column_config_schema.rs +38 -377
  99. package/src/rust/config/number_string_format.rs +0 -94
  100. package/src/rust/config/options.rs +3 -7
  101. package/src/rust/config/plugin_static_config.rs +0 -42
  102. package/src/rust/config/viewer_config.rs +47 -17
  103. package/src/rust/config/workspace_config.rs +158 -128
  104. package/src/rust/custom_elements/viewer.rs +187 -131
  105. package/src/rust/custom_events.rs +33 -18
  106. package/src/rust/derived.rs +266 -0
  107. package/src/rust/exprtk/tokenize.rs +0 -54
  108. package/src/rust/js/plugin.rs +1 -1
  109. package/src/rust/lib.rs +3 -0
  110. package/src/rust/presentation.rs +23 -4
  111. package/src/rust/queries/column_locator.rs +55 -48
  112. package/src/rust/queries/get_viewer_config.rs +1 -1
  113. package/src/rust/queries/plugin_column_styles.rs +22 -23
  114. package/src/rust/renderer/dispatch.rs +2 -0
  115. package/src/rust/renderer/plugin_config.rs +652 -487
  116. package/src/rust/renderer/props.rs +4 -0
  117. package/src/rust/renderer/registry.rs +2 -6
  118. package/src/rust/renderer/state.rs +205 -0
  119. package/src/rust/renderer.rs +201 -59
  120. package/src/rust/session/metadata.rs +30 -9
  121. package/src/rust/session/op_queue.rs +430 -0
  122. package/src/rust/session/panel_state.rs +327 -0
  123. package/src/rust/session/view_subscription.rs +42 -3
  124. package/src/rust/session.rs +900 -395
  125. package/src/rust/tasks/apply_global_filters.rs +49 -35
  126. package/src/rust/tasks/auto_pause.rs +22 -12
  127. package/src/rust/tasks/copy_export.rs +5 -0
  128. package/src/rust/tasks/create_panel.rs +34 -9
  129. package/src/rust/tasks/derive.rs +312 -0
  130. package/src/rust/tasks/mod.rs +6 -2
  131. package/src/rust/tasks/pipeline.rs +145 -89
  132. package/src/rust/tasks/reset_all.rs +31 -20
  133. package/src/rust/tasks/restore_derived.rs +200 -0
  134. package/src/rust/tasks/restore_panel.rs +153 -141
  135. package/src/rust/tasks/send_column_config.rs +56 -29
  136. package/src/rust/tasks/send_plugin_config.rs +52 -23
  137. package/src/rust/tasks/set_edit_mode.rs +33 -25
  138. package/src/rust/tasks/sync_update_panels.rs +39 -12
  139. package/src/rust/tasks/table_lifecycle.rs +70 -34
  140. package/src/rust/tasks/transactional_restore.rs +387 -0
  141. package/src/rust/tasks/update_theme.rs +45 -6
  142. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  143. package/src/rust/ui/containers/split_panel.rs +0 -41
  144. package/src/rust/ui/context_menu.rs +157 -18
  145. package/src/rust/ui/font_loader.rs +23 -35
  146. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  147. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  148. package/src/rust/ui/form/palette_selector.rs +0 -51
  149. package/src/rust/ui/portal.rs +4 -10
  150. package/src/rust/utils/browser/mod.rs +2 -3
  151. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  152. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  153. package/src/rust/utils/css.rs +0 -360
  154. package/src/rust/utils/local_fonts.rs +0 -36
  155. package/src/rust/utils/mod.rs +2 -3
  156. package/src/rust/utils/modal_position.rs +15 -21
  157. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  158. package/src/rust/utils/pubsub.rs +0 -1
  159. package/src/rust/workspace.rs +187 -140
  160. package/src/svg/derived.svg +6 -0
  161. package/src/themes/icons.css +1 -0
  162. package/src/themes/intl/ar.css +10 -0
  163. package/src/themes/intl/de.css +10 -0
  164. package/src/themes/intl/es.css +10 -0
  165. package/src/themes/intl/fr.css +10 -0
  166. package/src/themes/intl/ja.css +10 -0
  167. package/src/themes/intl/pt.css +10 -0
  168. package/src/themes/intl/zh.css +10 -0
  169. package/src/themes/intl.css +10 -0
  170. package/src/ts/extensions.ts +3 -36
  171. package/src/ts/perspective-viewer.ts +1 -1
  172. package/src/ts/plugin.ts +4 -1
  173. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  174. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  175. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  176. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  177. package/src/ts/ts-rs/TableRef.ts +9 -0
  178. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  179. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  180. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  181. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  182. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  183. package/dist/esm/perspective-viewer.inline.js +0 -21
  184. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  185. package/src/rust/tasks/restore_and_render.rs +0 -107
  186. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  187. package/src/rust/utils/tests/mod.rs +0 -14
  188. package/src/rust/utils/tests/pubsub.rs +0 -95
  189. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  190. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  191. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  192. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  193. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  194. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -24,7 +24,7 @@ use crate::components::column_selector::{EmptyColumn, InPlaceColumn, InvalidColu
24
24
  use crate::components::type_icon::TypeIcon;
25
25
  use crate::presentation::{DragDropContainer, Presentation};
26
26
  use crate::ui::intl_slug;
27
- use crate::utils::DragTarget;
27
+ use crate::utils::{DragTarget, left_click};
28
28
 
29
29
  #[derive(Properties, Derivative)]
30
30
  #[derivative(Clone(bound = ""))]
@@ -161,8 +161,6 @@ where
161
161
  if freeze && self.frozen_size.is_none() {
162
162
  let elem = self.elem.cast::<HtmlElement>().unwrap();
163
163
  self.frozen_size = Some({
164
- // `offset_width` and family are `i32`, but Chrome _really_
165
- // uses fractional pixel widths for these which can only be
166
164
  // recovered by parsing the generated stylesheet ...
167
165
  let txt = window()
168
166
  .unwrap()
@@ -178,7 +176,8 @@ where
178
176
  });
179
177
  true
180
178
  } else if !freeze {
181
- // Don't render because the invoker will do so through `dragdrop`.
179
+ // Don't render because the invoker will do so through
180
+ // `dragdrop`.
182
181
  self.frozen_size = None;
183
182
  false
184
183
  } else {
@@ -191,8 +190,8 @@ where
191
190
  fn view(&self, ctx: &Context<Self>) -> Html {
192
191
  let dragover = Callback::from(|_event: DragEvent| _event.prevent_default());
193
192
 
194
- // On dragleave, signal the parent but no need to redraw as parent will call
195
- // `change()` when resetting props.
193
+ // On dragleave, signal the parent but no need to redraw as parent will
194
+ // call `change()` when resetting props.
196
195
  let drag_container = DragDropContainer::new(
197
196
  {
198
197
  let total = ctx.props().children.len();
@@ -244,7 +243,7 @@ where
244
243
  .map(|x| V::is_self_move(x))
245
244
  .unwrap_or_default();
246
245
 
247
- let close = ctx.props().parent.callback(|_| V::close(0));
246
+ let close = left_click(ctx.props().parent.callback(|_| V::close(0)));
248
247
  let dragenter = ctx.props().parent.callback({
249
248
  let container_noderef = container_noderef.clone();
250
249
  move |event: DragEvent| {
@@ -332,7 +331,7 @@ where
332
331
  .into_iter()
333
332
  .enumerate()
334
333
  .map(|(idx, column)| {
335
- let close = ctx.props().parent.callback(move |_| V::close(idx));
334
+ let close = left_click(ctx.props().parent.callback(move |_| V::close(idx)));
336
335
  let dragenter = ctx.props().parent.callback({
337
336
  let link = ctx.link().clone();
338
337
  let container_noderef = container_noderef.clone();
@@ -441,7 +440,7 @@ where
441
440
  </div>
442
441
  <span
443
442
  class="toggle-mode is_column_active"
444
- onmousedown={ctx.props().parent.callback(move |_| V::close(0))}
443
+ onmousedown={left_click(ctx.props().parent.callback(move |_| V::close(0)))}
445
444
  />
446
445
  </div>
447
446
  } else if show_empty {
@@ -49,12 +49,14 @@ pub enum MainPanelMsg {
49
49
  BeforeResize(web_sys::Event),
50
50
 
51
51
  /// A right-click opened the panel context menu (target panel id, client
52
- /// x, y). Fired by the imperative `contextmenu` listener on the panel
53
- /// container — see `_contextmenu_listener` — and by each `PanelTab`.
52
+ /// x, y). Fired by the imperative `contextmenu` listener on the viewer
53
+ /// host — see `_contextmenu_listener` — and by each `PanelTab`.
54
54
  /// `Some(id)` activates that panel and opens its
55
55
  /// [`PanelMenu`](crate::components::panel_menu::PanelMenu) at the
56
- /// cursor; `None` is the EMPTY stage (zero panels) — a target-less menu
57
- /// offering only "New", whose items create the first panel.
56
+ /// cursor; `None` (no frame on the composed path — status bar,
57
+ /// sidebars, the empty stage) resolves to the ACTIVE panel, else to a
58
+ /// target-less menu offering only "New", whose items create the first
59
+ /// panel.
58
60
  ContextMenu(Option<String>, f64, f64),
59
61
 
60
62
  /// Dismiss the panel context menu (the menu session ended).
@@ -111,6 +111,8 @@ impl MainPanel {
111
111
  if self.listener_target.as_ref() != Some(&el) {
112
112
  if self.listener_target.is_some() {
113
113
  self.inserted.clear();
114
+ self.pending_insertions.clear();
115
+ self.suspect_closes.clear();
114
116
  }
115
117
 
116
118
  let _ = el.add_event_listener_with_callback(
@@ -183,11 +185,13 @@ impl MainPanel {
183
185
  // horizontal) rather than the default (path `[]`), which would
184
186
  // *stack* into the root tab-layout — a stack only renders its
185
187
  // selected tab, and without frame chrome there's no tab-bar to
186
- // reach the others. Splitting keeps every panel visible side-by-side.
188
+ // reach the others. Splitting keeps every panel visible
189
+ // side-by-side.
187
190
  let index = self.inserted.len();
188
191
  let path = JsValue::from(js_sys::Array::of1(&JsValue::from_f64(index as f64)));
189
192
  let _ = layout.insert_panel(name, path, JsValue::from_bool(true));
190
193
  self.inserted.push(name.to_owned());
194
+ self.pending_insertions.insert(name.to_owned());
191
195
  }
192
196
 
193
197
  let mut pending = std::mem::take(&mut self.pending_removals);
@@ -197,6 +201,7 @@ impl MainPanel {
197
201
  }
198
202
 
199
203
  if pending.insert(name.clone()) {
204
+ self.pending_insertions.remove(name);
200
205
  let _ = layout.remove_panel(name);
201
206
  }
202
207
 
@@ -58,19 +58,21 @@ impl MainPanel {
58
58
  let on_dismiss_warning = dismiss_render_warning_callback(session, renderer);
59
59
 
60
60
  // Each panel's plugin is mounted in the viewer's light DOM under its
61
- // panel-id slot (see `renderer::activate`). Render one `<regular-layout>`
62
- // cell per panel: a concrete `<div name=…>` wrapper (which
63
- // `regular-layout` grid-places via its `::slotted([name])` rule — that
64
- // only matches *direct*, non-`<slot>` children, hence the wrapper) whose
65
- // inner forwarding `<slot>` projects the light-DOM plugin into the placed
66
- // cell. The `<regular-layout>` element is ALWAYS rendered — at zero
67
- // panels it is merely empty (its tree collapses to a childless split,
68
- // and it paints nothing of its own), which keeps the stage CSS
69
- // (viewer.css keys the stage background + inset bleed on the element)
70
- // and the attached listeners alive across empty↔populated transitions.
61
+ // panel-id slot (see `renderer::activate`). Render one
62
+ // `<regular-layout>` cell per panel: a concrete `<div name=…>`
63
+ // wrapper (which `regular-layout` grid-places via its
64
+ // `::slotted([name])` rule — that only matches *direct*,
65
+ // non-`<slot>` children, hence the wrapper) whose
66
+ // inner forwarding `<slot>` projects the light-DOM plugin into the
67
+ // placed cell. The `<regular-layout>` element is ALWAYS
68
+ // rendered — at zero panels it is merely empty (its tree
69
+ // collapses to a childless split, and it paints nothing of its
70
+ // own), which keeps the stage CSS (viewer.css keys the stage
71
+ // background + inset bleed on the element) and the attached
72
+ // listeners alive across empty↔populated transitions.
71
73
  let plugin_area = {
72
- // The active panel (whose engines the status bar/settings target) is
73
- // the one the active renderer is bound to.
74
+ // The active panel (whose engines the status bar/settings target)
75
+ // is the one the active renderer is bound to.
74
76
  let active_slot = renderer.slot_name();
75
77
  // Panel-count chrome (`single`/`multi`, closable, draggable)
76
78
  let placed_count = ctx.props().workspace.placed_count();
@@ -99,20 +101,25 @@ impl MainPanel {
99
101
  class.push("active");
100
102
  }
101
103
  } else {
102
- // Lone panel: can't be closed to zero — hide the close button
103
- // (see viewer.css) and skip the active outline.
104
+ // Lone panel: can't be closed to zero — hide the close
105
+ // button (see viewer.css) and
106
+ // skip the active outline.
104
107
  class.push("single");
105
108
  }
106
109
 
107
- // Activate this panel on pointerdown anywhere in it (body or
108
- // titlebar), so *working* in a panel — not only clicking its tab
109
- // — targets it with the shared settings/toolbar. regular-layout
110
- // emits `regular-layout-select` on tab clicks only, which left
111
- // body interaction unable to change the active panel. Tab clicks
112
- // still also fire `-select`, but that dispatch is async (after
113
- // `restore`) so it lands after this synchronous handler — within a
114
- // stack, switching tabs still resolves to the clicked panel.
115
- // Re-activating the already-active panel is a no-op upstream.
110
+ // Activate this panel on pointerdown anywhere in it (body
111
+ // or titlebar), so *working* in a panel
112
+ // — not only clicking its tab — targets
113
+ // it with the shared settings/toolbar. regular-layout
114
+ // emits `regular-layout-select` on tab clicks only, which
115
+ // left body interaction unable to
116
+ // change the active panel. Tab clicks
117
+ // still also fire `-select`, but that dispatch is async
118
+ // (after `restore`) so it lands after
119
+ // this synchronous handler — within a
120
+ // stack, switching tabs still resolves to the clicked
121
+ // panel. Re-activating the
122
+ // already-active panel is a no-op upstream.
116
123
  let on_activate = ctx.props().on_activate_panel.clone();
117
124
  let activate_name = name.clone();
118
125
  let onpointerdown = Callback::from(move |_: web_sys::PointerEvent| {
@@ -121,25 +128,33 @@ impl MainPanel {
121
128
  }
122
129
  });
123
130
 
124
- // Right-click anywhere in the panel opens the context menu. This
125
- // is handled by the imperative `contextmenu` listener on the
131
+ // Right-click anywhere in the panel opens the context menu.
132
+ // This is handled by the imperative
133
+ // `contextmenu` listener on the
126
134
  // panel container (see `_contextmenu_listener`), NOT a Yew
127
- // `oncontextmenu` here: the plugin body is light-DOM attached by
128
- // the renderer, so Yew's delegated handler — which walks the vdom
129
- // — never matches a right-click on it, leaving the native menu.
135
+ // `oncontextmenu` here: the plugin body is light-DOM
136
+ // attached by the renderer, so Yew's
137
+ // delegated handler — which walks the vdom
138
+ // — never matches a right-click on it, leaving the native
139
+ // menu.
130
140
 
131
141
  // `<regular-layout-frame>` gives each panel a titlebar (its
132
- // `slot="tab"`) and a `::part(container)`. Two forwarding `<slot>`s
133
- // project our light-DOM nodes in: `name` → the plugin (into the
134
- // container), and `tab-<name>` (with `slot="tab"`) → this panel's
135
- // `<PanelTab>` (into the titlebar). Both targets live in the
136
- // viewer's light DOM, so the slots — being in the viewer's shadow —
137
- // collect them and forward them one level deeper into the frame.
142
+ // `slot="tab"`) and a `::part(container)`. Two forwarding
143
+ // `<slot>`s project our light-DOM nodes
144
+ // in: `name` → the plugin (into the
145
+ // container), and `tab-<name>` (with `slot="tab"`) → this
146
+ // panel's `<PanelTab>` (into the
147
+ // titlebar). Both targets live in the
148
+ // viewer's light DOM, so the slots — being in the viewer's
149
+ // shadow — collect them and forward
150
+ // them one level deeper into the frame.
138
151
  //
139
- // The frame no longer carries the theme: the per-panel theme is
140
- // applied directly to the light-DOM plugin + tab (each stamped with
141
- // a `theme` attribute) via the `perspective-viewer [theme="X"]`
142
- // document rules, so it themes through the native cascade instead
152
+ // The frame no longer carries the theme: the per-panel
153
+ // theme is applied directly to the
154
+ // light-DOM plugin + tab (each stamped with
155
+ // a `theme` attribute) via the `perspective-viewer
156
+ // [theme="X"]` document rules, so it
157
+ // themes through the native cascade instead
143
158
  // of a scraped/inlined `--psp-*` block.
144
159
  html! {
145
160
  <regular-layout-frame
@@ -154,10 +169,11 @@ impl MainPanel {
154
169
  }
155
170
  });
156
171
 
157
- // Per-panel tab titles: regular-layout's tabs render their label from
158
- // a `--regular-layout-<id>--title` custom property (a CSS string). We
159
- // set one per titled panel on the `<regular-layout>` host; they
160
- // inherit into each frame/tab's shadow.
172
+ // Per-panel tab titles: regular-layout's tabs render their label
173
+ // from a `--regular-layout-<id>--title` custom property
174
+ // (a CSS string). We set one per titled panel on the
175
+ // `<regular-layout>` host; they inherit into each
176
+ // frame/tab's shadow.
161
177
  let title_style = ctx
162
178
  .props()
163
179
  .panel_titles
@@ -171,10 +187,11 @@ impl MainPanel {
171
187
 
172
188
  // One `<PanelTab>` per panel, mounted in the viewer's light DOM and
173
189
  // forwarded into its frame's titlebar by the `tab-<id>` slot above.
174
- // Rendered as siblings of `<regular-layout>` (their DOM placement is
175
- // irrelevant — each portals itself onto the viewer host). `on_select`
176
- // drives regular-layout's own `select` (the `regular-layout-select`
177
- // listener picks up activation).
190
+ // Rendered as siblings of `<regular-layout>` (their DOM placement
191
+ // is irrelevant — each portals itself onto the viewer
192
+ // host). `on_select` drives regular-layout's own
193
+ // `select` (the `regular-layout-select` listener picks
194
+ // up activation).
178
195
  let viewer_elem = ctx.props().presentation.viewer_elem().clone();
179
196
  // A lone panel can't be closed to zero, and has nowhere to be
180
197
  // dragged — so it's neither closable nor draggable; its tab also
@@ -199,12 +216,13 @@ impl MainPanel {
199
216
  // `regular-layout` on the re-render. Driving the layout first — and
200
217
  // discovering the close via the `regular-layout-update` DOM event —
201
218
  // ran the layout-update resize fan-out over a still-present-but-
202
- // doomed panel, spawning an (uncancellable) draw that then raced its
203
- // disposal ("restore() called before load()").
219
+ // doomed panel, spawning an (uncancellable) draw that then raced
220
+ // its disposal ("restore() called before load()").
204
221
  let on_close = ctx.props().on_close_panel.clone();
205
- // Commit an in-tab title edit to that panel's own session — the same
206
- // `title_changed` → `TitlesChanged` refresh loop that used to be
207
- // driven by the status bar re-renders the tab with the new title.
222
+ // Commit an in-tab title edit to that panel's own session — the
223
+ // same `title_changed` → `TitlesChanged` refresh loop
224
+ // that used to be driven by the status bar re-renders
225
+ // the tab with the new title.
208
226
  let on_rename = {
209
227
  let workspace = ctx.props().workspace.clone();
210
228
  Callback::from(move |(id, title): (String, Option<String>)| {
@@ -406,6 +424,7 @@ impl MainPanel {
406
424
  on_reset={ctx.props().on_reset.clone()}
407
425
  {on_new_panel}
408
426
  session_props={ctx.props().session_props.clone()}
427
+ render_error={ctx.props().renderer_props.render_error.clone()}
409
428
  presentation_props={ctx.props().presentation_props.clone()}
410
429
  is_settings_open={ctx.props().is_settings_open}
411
430
  update_count={ctx.props().update_count}
@@ -19,7 +19,8 @@ use perspective_js::utils::JsValueSerdeExt;
19
19
  use wasm_bindgen::prelude::*;
20
20
  use yew::prelude::*;
21
21
 
22
- use super::MainPanel;
22
+ use super::{MainPanel, MainPanelMsg};
23
+ use crate::ApiFuture;
23
24
  use crate::components::panel_menu::PanelCommand;
24
25
  use crate::js::{Layout, RegularLayout};
25
26
  use crate::renderer::SUBPIXEL_EPSILON;
@@ -58,15 +59,40 @@ impl MainPanel {
58
59
 
59
60
  let layout: RegularLayout = el.unchecked_into();
60
61
  let mut closed = Vec::new();
62
+ let mut pending_insertions = std::mem::take(&mut self.pending_insertions);
63
+ let mut suspect_closes = std::mem::take(&mut self.suspect_closes);
61
64
  self.inserted.retain(|id| {
62
65
  if layout.contains_panel(id) {
66
+ pending_insertions.remove(id);
67
+ suspect_closes.remove(id);
68
+ true
69
+ } else if pending_insertions.contains(id) {
70
+ // Issued to the layout but not yet in a committed tree —
71
+ // the pre-commit window, not a close.
72
+ true
73
+ } else if ctx.props().panel_ids.iter().any(|p| p.as_str() == id)
74
+ && suspect_closes.insert(id.clone())
75
+ {
63
76
  true
64
77
  } else {
78
+ suspect_closes.remove(id);
65
79
  closed.push(id.clone());
66
80
  false
67
81
  }
68
82
  });
69
83
 
84
+ self.pending_insertions = pending_insertions;
85
+ let recheck = !suspect_closes.is_empty();
86
+ self.suspect_closes = suspect_closes;
87
+ if recheck {
88
+ let link = ctx.link().clone();
89
+ ApiFuture::spawn(async move {
90
+ crate::utils::request_animation_frame().await;
91
+ link.send_message(MainPanelMsg::LayoutUpdated);
92
+ Ok(())
93
+ });
94
+ }
95
+
70
96
  let closed_any = !closed.is_empty();
71
97
  for id in closed {
72
98
  self.pending_removals.remove(&id);
@@ -160,6 +186,18 @@ impl MainPanel {
160
186
  x: f64,
161
187
  y: f64,
162
188
  ) -> bool {
189
+ // A frame-less right-click (status bar, sidebars) targets the
190
+ // ACTIVE panel, validated against the workspace so a stale slot
191
+ // name after an eject falls back to the target-less stage menu.
192
+ let id = id.or_else(|| {
193
+ ctx.props().renderer.slot_name().filter(|name| {
194
+ ctx.props()
195
+ .workspace
196
+ .panel(&crate::workspace::PanelId::from(name.as_str()))
197
+ .is_some()
198
+ })
199
+ });
200
+
163
201
  // Make the right-clicked panel active so active-targeting
164
202
  // commands (e.g. Reset) act on it; then show the menu.
165
203
  if let Some(id) = &id {
@@ -185,14 +185,15 @@ pub struct MainPanel {
185
185
  /// and attached alongside the others.
186
186
  _layout_before_resize_listener: Closure<dyn FnMut(web_sys::Event)>,
187
187
 
188
- /// `contextmenu` listener on the panel *container* — one stable attach
189
- /// point covering the whole stage, independent of the layout reconcile;
190
- /// at zero panels it opens the stage menu. Kept alive here and attached
191
- /// once in `rendered`. Imperative — not a Yew `oncontextmenu` — because
192
- /// the plugin body is light-DOM attached by the renderer (its DOM parent
193
- /// is the host, not the frame), so Yew's delegated handler never matches
194
- /// a right-click there. A native listener catches it via composed
195
- /// bubbling and resolves the panel from the path.
188
+ /// `contextmenu` listener on the viewer HOST element — one stable attach
189
+ /// point covering every surface (panels, status bar, sidebars),
190
+ /// independent of the layout reconcile; attached once in `rendered` and
191
+ /// REMOVED in `destroy`, since the host outlives this component's Yew
192
+ /// app. Imperative — not a Yew `oncontextmenu` — because the plugin body
193
+ /// is light-DOM attached by the renderer (its DOM parent is the host,
194
+ /// not the frame), so Yew's delegated handler never matches a
195
+ /// right-click there; a native listener sees every surface — shadow
196
+ /// chrome and slotted light DOM alike — on the composed path.
196
197
  _contextmenu_listener: Closure<dyn FnMut(web_sys::Event)>,
197
198
 
198
199
  /// The `<regular-layout>` ELEMENT the layout listeners are attached to.
@@ -211,6 +212,20 @@ pub struct MainPanel {
211
212
  /// layout and not yet committed.
212
213
  pending_removals: HashSet<String>,
213
214
 
215
+ /// Ids whose `insertPanel` has been issued to the layout and not yet
216
+ /// observed in a committed tree: layout commits are async, so a
217
+ /// multi-insert reconcile sees `regular-layout-update` events BETWEEN
218
+ /// commits, and close detection must not mistake the pre-commit window
219
+ /// for a user close (it once disposed the second of two restored
220
+ /// panels mid-restore).
221
+ pending_insertions: HashSet<String>,
222
+
223
+ /// MODEL-held panels observed absent from exactly one committed tree,
224
+ /// awaiting the next `regular-layout-update` (rescheduled one frame out)
225
+ /// to confirm a layout-originated close — an unrelated removal's commit
226
+ /// can report a live sibling absent transiently.
227
+ suspect_closes: HashSet<String>,
228
+
214
229
  /// Per-panel `ResizeObserver`s, keyed by panel id, each observing that
215
230
  /// panel's slotted plugin element and resizing only that panel's
216
231
  /// `Renderer`. Bound in `BeforeResize` for the DRAGGED panel only (it is
@@ -226,9 +241,9 @@ pub struct MainPanel {
226
241
  hidden_tabs: HashSet<String>,
227
242
 
228
243
  /// Open context menu as `(client x, client y, target panel id)`; outer
229
- /// `None` when closed. A `None` *panel id* is the empty-stage menu (zero
230
- /// panels — "New" only). Rendered as a cursor-anchored
231
- /// [`PanelMenu`](super::panel_menu::PanelMenu) overlay.
244
+ /// `None` when closed. A `None` *panel id* is the target-less menu (no
245
+ /// active panel to resolve to — "New" only). Rendered as a
246
+ /// cursor-anchored [`PanelMenu`](super::panel_menu::PanelMenu) overlay.
232
247
  context_menu: Option<(f64, f64, Option<String>)>,
233
248
 
234
249
  /// Id of the currently maximized panel (via `regular-layout.maximize`), or
@@ -280,27 +295,29 @@ impl Component for MainPanel {
280
295
  }) as Box<dyn FnMut(web_sys::Event)>);
281
296
 
282
297
  // `preventDefault()` synchronously suspends the layout's resize commit
283
- // (the event is cancelable); the component then pre-sizes each panel and
284
- // calls `resumeResize` to release it (see `MainPanelMsg::BeforeResize`).
298
+ // (the event is cancelable); the component then pre-sizes each panel
299
+ // and calls `resumeResize` to release it (see
300
+ // `MainPanelMsg::BeforeResize`).
285
301
  let before_resize_cb = ctx.link().callback(MainPanelMsg::BeforeResize);
286
302
  let before_resize_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
287
303
  event.prevent_default();
288
304
  before_resize_cb.emit(event);
289
305
  }) as Box<dyn FnMut(web_sys::Event)>);
290
306
 
291
- // Imperative `contextmenu` listener: a right-click anywhere in a panel
292
- // (the plugin body included) opens the panel menu. The plugin body is
293
- // light-DOM attached by the renderer, so a Yew `oncontextmenu` on the
294
- // frame never matches it (Yew walks the vdom, not the composed path).
295
- // This native listener resolves the panel from the
296
- // `<regular-layout-frame name=…>` on the event's composed path, then
297
- // suppresses the browser menu and emits. On the EMPTY stage (zero
298
- // panels — the persistent `<regular-layout>` has no frame
299
- // descendants), it opens the stage menu instead, whose "New" items
300
- // create the first panel.
307
+ // Imperative `contextmenu` listener: a right-click anywhere in the
308
+ // element opens the panel menu — targeting the frame on the event's
309
+ // composed path, or `None` (resolved to the ACTIVE panel, else the
310
+ // stage menu, in `on_context_menu`, where props live) from any
311
+ // other surface. Two pass-throughs keep the NATIVE menu: editable
312
+ // targets (inputs, textareas, contenteditable — cut/copy/paste),
313
+ // and anything inside an open `PortalModal` (every modal host is a
314
+ // light-DOM viewer child in the modal slot — this menu included, so
315
+ // without the carve-out a right-click in any open dropdown would
316
+ // re-open the menu underneath it).
301
317
  let contextmenu_cb = ctx
302
318
  .link()
303
319
  .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y));
320
+ let viewer_elem = ctx.props().presentation.viewer_elem().clone();
304
321
  let contextmenu_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
305
322
  // Shift+right-click passes through to the native browser menu.
306
323
  if event.unchecked_ref::<web_sys::MouseEvent>().shift_key() {
@@ -308,11 +325,34 @@ impl Component for MainPanel {
308
325
  }
309
326
 
310
327
  let path = event.composed_path();
328
+ let is_editable = path.get(0).dyn_ref::<web_sys::HtmlElement>().is_some_and(
329
+ |el: &web_sys::HtmlElement| {
330
+ el.tag_name().eq_ignore_ascii_case("input")
331
+ || el.tag_name().eq_ignore_ascii_case("textarea")
332
+ || el.is_content_editable()
333
+ },
334
+ );
335
+
336
+ if is_editable {
337
+ return;
338
+ }
339
+
311
340
  let mut panel_id = None;
312
341
  for i in 0..path.length() {
313
342
  let node = path.get(i);
314
- if let Some(el) = node.dyn_ref::<web_sys::Element>()
315
- && el.tag_name().eq_ignore_ascii_case("regular-layout-frame")
343
+ let Some(el) = node.dyn_ref::<web_sys::Element>() else {
344
+ continue;
345
+ };
346
+
347
+ if el == viewer_elem.unchecked_ref::<web_sys::Element>() {
348
+ break;
349
+ }
350
+
351
+ if el.slot() == crate::ui::MODAL_SLOT {
352
+ return;
353
+ }
354
+
355
+ if el.tag_name().eq_ignore_ascii_case("regular-layout-frame")
316
356
  && let Some(name) = el.get_attribute("name")
317
357
  {
318
358
  panel_id = Some(name);
@@ -320,29 +360,9 @@ impl Component for MainPanel {
320
360
  }
321
361
  }
322
362
 
323
- let is_empty_stage = || {
324
- event
325
- .current_target()
326
- .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
327
- .is_some_and(|el| {
328
- el.query_selector("regular-layout-frame")
329
- .ok()
330
- .flatten()
331
- .is_none()
332
- })
333
- };
334
-
335
- if let Some(id) = panel_id {
336
- event.prevent_default();
337
- let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
338
- contextmenu_cb.emit((Some(id), mouse.client_x() as f64, mouse.client_y() as f64));
339
- } else if is_empty_stage() {
340
- event.prevent_default();
341
- let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
342
- contextmenu_cb.emit((None, mouse.client_x() as f64, mouse.client_y() as f64));
343
- }
344
- // With panels present, a click outside every frame lets the
345
- // native menu through.
363
+ event.prevent_default();
364
+ let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
365
+ contextmenu_cb.emit((panel_id, mouse.client_x() as f64, mouse.client_y() as f64));
346
366
  }) as Box<dyn FnMut(web_sys::Event)>);
347
367
 
348
368
  let staged_sub = {
@@ -368,6 +388,8 @@ impl Component for MainPanel {
368
388
  layout_ref: NodeRef::default(),
369
389
  inserted: Vec::new(),
370
390
  pending_removals: HashSet::new(),
391
+ pending_insertions: HashSet::new(),
392
+ suspect_closes: HashSet::new(),
371
393
  _layout_update_listener: listener,
372
394
  _layout_select_listener: select_listener,
373
395
  _layout_before_resize_listener: before_resize_listener,
@@ -404,16 +426,20 @@ impl Component for MainPanel {
404
426
  }
405
427
 
406
428
  fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
407
- // The `contextmenu` listener attaches to the panel CONTAINER — one
408
- // stable attach point covering the whole stage, decoupled from the
409
- // layout reconcile. Panel right-clicks bubble to it through the
410
- // layout on the composed path; at zero panels it serves the stage
411
- // menu.
412
- if first_render && let Some(el) = self.main_panel_ref.cast::<web_sys::HtmlElement>() {
413
- let _ = el.add_event_listener_with_callback(
414
- "contextmenu",
415
- self._contextmenu_listener.as_ref().unchecked_ref(),
416
- );
429
+ // The `contextmenu` listener attaches to the viewer HOST element —
430
+ // one stable attach point covering every surface (panels, status
431
+ // bar, sidebars), decoupled from the layout reconcile. Panel
432
+ // right-clicks reach it through their frames on the composed path;
433
+ // everything else resolves to the active panel (or the stage menu).
434
+ if first_render {
435
+ let _ = ctx
436
+ .props()
437
+ .presentation
438
+ .viewer_elem()
439
+ .add_event_listener_with_callback(
440
+ "contextmenu",
441
+ self._contextmenu_listener.as_ref().unchecked_ref(),
442
+ );
417
443
  }
418
444
 
419
445
  self.size_staging_wrappers();
@@ -425,7 +451,19 @@ impl Component for MainPanel {
425
451
  self.render(ctx)
426
452
  }
427
453
 
428
- fn destroy(&mut self, _ctx: &Context<Self>) {}
454
+ fn destroy(&mut self, ctx: &Context<Self>) {
455
+ // The host element outlives this component (`delete()` tears down
456
+ // the Yew app, not the custom element), so the listener must not
457
+ // dangle into a dropped `Closure`.
458
+ let _ = ctx
459
+ .props()
460
+ .presentation
461
+ .viewer_elem()
462
+ .remove_event_listener_with_callback(
463
+ "contextmenu",
464
+ self._contextmenu_listener.as_ref().unchecked_ref(),
465
+ );
466
+ }
429
467
  }
430
468
 
431
469
  impl MainPanel {