@perspective-dev/viewer 4.5.1 → 5.0.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 (212) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  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/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/esm/extensions.d.ts +22 -3
  17. package/dist/esm/perspective-viewer.cdn.d.ts +3 -0
  18. package/dist/esm/perspective-viewer.d.ts +9 -0
  19. package/dist/esm/perspective-viewer.inline.d.ts +3 -0
  20. package/dist/esm/perspective-viewer.inline.js +20 -1
  21. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  22. package/dist/esm/perspective-viewer.js +20 -1
  23. package/dist/esm/perspective-viewer.js.map +4 -4
  24. package/dist/esm/plugin.d.ts +71 -5
  25. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  26. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  27. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  28. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  29. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  30. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  31. package/dist/esm/ts-rs/OnUpdateData.d.ts +6 -0
  32. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  33. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  34. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  35. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  36. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  37. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +4 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  41. package/dist/wasm/perspective-viewer.d.ts +213 -79
  42. package/dist/wasm/perspective-viewer.js +537 -189
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +36 -28
  45. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  46. package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  47. package/package.json +3 -2
  48. package/src/css/_column_dropdown.css +14 -0
  49. package/src/css/_context_menu.css +14 -0
  50. package/src/css/_dropdown_menu.css +14 -0
  51. package/src/css/_filter_dropdown.css +14 -0
  52. package/src/css/_function_dropdown.css +14 -0
  53. package/src/css/_viewer_bundle.css +41 -0
  54. package/src/css/column-settings-panel.css +1 -1
  55. package/src/css/containers/context-menu.css +101 -0
  56. package/src/css/panel-tab.css +193 -0
  57. package/src/css/plugin-selector.css +7 -10
  58. package/src/css/status-bar.css +75 -81
  59. package/src/css/viewer.css +181 -71
  60. package/src/rust/components/column_dropdown.rs +43 -33
  61. package/src/rust/components/column_selector/active_column.rs +7 -18
  62. package/src/rust/components/column_selector/aggregate_selector.rs +4 -10
  63. package/src/rust/components/column_selector/config_selector.rs +33 -84
  64. package/src/rust/components/column_selector/empty_column.rs +0 -3
  65. package/src/rust/components/column_selector/filter_column.rs +5 -18
  66. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  67. package/src/rust/components/column_selector/invalid_column.rs +0 -4
  68. package/src/rust/components/column_selector/sort_column.rs +3 -6
  69. package/src/rust/components/column_selector.rs +5 -14
  70. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs +0 -3
  71. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +0 -3
  72. package/src/rust/components/column_settings_sidebar.rs +0 -3
  73. package/src/rust/components/containers/dropdown_menu.rs +1 -3
  74. package/src/rust/components/containers/scroll_panel.rs +1 -3
  75. package/src/rust/components/containers/split_panel.rs +175 -66
  76. package/src/rust/components/containers/tab_list.rs +0 -3
  77. package/src/rust/components/context_menu.rs +130 -0
  78. package/src/rust/components/datetime_column_style.rs +0 -3
  79. package/src/rust/components/empty_row.rs +0 -3
  80. package/src/rust/components/expression_editor.rs +2 -3
  81. package/src/rust/components/filter_dropdown.rs +3 -3
  82. package/src/rust/components/font_loader.rs +2 -2
  83. package/src/rust/components/form/code_editor.rs +0 -3
  84. package/src/rust/components/form/debug.rs +3 -4
  85. package/src/rust/components/function_dropdown.rs +3 -3
  86. package/src/rust/components/global_filter_bar.rs +76 -0
  87. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  88. package/src/rust/components/main_panel/msg.rs +62 -0
  89. package/src/rust/components/main_panel/presize.rs +220 -0
  90. package/src/rust/components/main_panel/reconcile.rs +224 -0
  91. package/src/rust/components/main_panel/render.rs +385 -0
  92. package/src/rust/components/main_panel/update.rs +184 -0
  93. package/src/rust/components/main_panel.rs +311 -71
  94. package/src/rust/components/mod.rs +4 -0
  95. package/src/rust/components/number_series_style.rs +0 -3
  96. package/src/rust/components/panel_menu.rs +486 -0
  97. package/src/rust/components/panel_tab.rs +505 -0
  98. package/src/rust/components/plugin_selector.rs +0 -3
  99. package/src/rust/components/plugin_tab.rs +1 -6
  100. package/src/rust/components/portal.rs +5 -2
  101. package/src/rust/components/render_warning.rs +0 -3
  102. package/src/rust/components/settings_panel.rs +13 -5
  103. package/src/rust/components/status_bar.rs +113 -180
  104. package/src/rust/components/status_indicator.rs +6 -9
  105. package/src/rust/components/string_column_style.rs +0 -3
  106. package/src/rust/components/style/mod.rs +65 -27
  107. package/src/rust/components/style/style_provider.rs +51 -16
  108. package/src/rust/components/style_controls/number_string_format.rs +1 -8
  109. package/src/rust/components/type_icon.rs +1 -4
  110. package/src/rust/components/viewer/filters.rs +133 -0
  111. package/src/rust/components/viewer/msg.rs +180 -0
  112. package/src/rust/components/viewer/panels.rs +326 -0
  113. package/src/rust/components/viewer/render.rs +243 -0
  114. package/src/rust/components/viewer/settings.rs +402 -0
  115. package/src/rust/components/viewer/snapshots.rs +134 -0
  116. package/src/rust/components/viewer/wiring.rs +505 -0
  117. package/src/rust/components/viewer.rs +243 -758
  118. package/src/rust/config/export_method.rs +1 -1
  119. package/src/rust/config/mod.rs +4 -0
  120. package/src/rust/config/options.rs +71 -0
  121. package/src/rust/config/viewer_config.rs +24 -2
  122. package/src/rust/config/workspace_config.rs +92 -0
  123. package/src/rust/custom_elements/copy_dropdown.rs +9 -3
  124. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  125. package/src/rust/custom_elements/export_dropdown.rs +9 -3
  126. package/src/rust/custom_elements/viewer.rs +1015 -286
  127. package/src/rust/custom_events.rs +212 -61
  128. package/src/rust/js/mod.rs +2 -0
  129. package/src/rust/js/plugin.rs +297 -10
  130. package/src/rust/js/regular_layout.rs +258 -0
  131. package/src/rust/lib.rs +20 -2
  132. package/src/rust/presentation.rs +66 -33
  133. package/src/rust/queries/get_viewer_config.rs +29 -11
  134. package/src/rust/queries/plugin_column_styles.rs +2 -2
  135. package/src/rust/renderer/activate.rs +41 -14
  136. package/src/rust/renderer/dispatch.rs +436 -0
  137. package/src/rust/renderer/plugin_config.rs +495 -0
  138. package/src/rust/renderer/registry.rs +18 -6
  139. package/src/rust/renderer/render_timer.rs +29 -10
  140. package/src/rust/renderer.rs +350 -827
  141. package/src/rust/session/column_defaults_update.rs +26 -8
  142. package/src/rust/session/metadata.rs +11 -1
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +753 -251
  145. package/src/rust/tasks/apply_global_filters.rs +89 -0
  146. package/src/rust/tasks/auto_pause.rs +171 -0
  147. package/src/rust/tasks/copy_export.rs +29 -19
  148. package/src/rust/tasks/create_panel.rs +243 -0
  149. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  150. package/src/rust/tasks/edit_expression.rs +4 -4
  151. package/src/rust/tasks/eject.rs +64 -23
  152. package/src/rust/tasks/mod.rs +28 -4
  153. package/src/rust/tasks/pipeline.rs +411 -0
  154. package/src/rust/tasks/presize_panels.rs +324 -0
  155. package/src/rust/tasks/reset_all.rs +22 -12
  156. package/src/rust/tasks/resize_observer.rs +124 -87
  157. package/src/rust/tasks/restore_and_render.rs +80 -86
  158. package/src/rust/tasks/restore_panel.rs +145 -0
  159. package/src/rust/tasks/send_column_config.rs +5 -2
  160. package/src/rust/tasks/send_plugin_config.rs +5 -2
  161. package/src/rust/tasks/set_edit_mode.rs +59 -0
  162. package/src/rust/tasks/sync_update_panels.rs +152 -0
  163. package/src/rust/tasks/table_lifecycle.rs +158 -0
  164. package/src/rust/tasks/update_theme.rs +56 -12
  165. package/src/rust/utils/completion.rs +56 -0
  166. package/src/rust/utils/custom_element.rs +5 -0
  167. package/src/rust/utils/debounce.rs +130 -7
  168. package/src/rust/utils/mod.rs +6 -0
  169. package/src/rust/utils/pending_effects.rs +62 -0
  170. package/src/rust/{components/style/local_style.rs → utils/spawn.rs} +17 -19
  171. package/src/rust/utils/weak_scope.rs +0 -10
  172. package/src/rust/workspace.rs +846 -0
  173. package/src/svg/active-indicator.svg +3 -0
  174. package/src/themes/botanical.css +7 -2
  175. package/src/themes/defaults.css +54 -2
  176. package/src/themes/dracula.css +8 -3
  177. package/src/themes/gruvbox-dark.css +7 -2
  178. package/src/themes/gruvbox.css +5 -2
  179. package/src/themes/icons.css +4 -2
  180. package/src/themes/monokai.css +7 -2
  181. package/src/themes/phosphor.css +7 -2
  182. package/src/themes/pro-dark.css +7 -2
  183. package/src/themes/pro.css +2 -1
  184. package/src/themes/solarized-dark.css +5 -2
  185. package/src/themes/solarized.css +5 -2
  186. package/src/themes/vaporwave.css +7 -2
  187. package/src/ts/bootstrap.ts +8 -7
  188. package/src/ts/extensions.ts +30 -2
  189. package/src/ts/perspective-viewer.ts +10 -0
  190. package/src/ts/plugin.ts +72 -5
  191. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  192. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  193. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  194. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  195. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  196. package/src/ts/ts-rs/Layout.ts +9 -0
  197. package/src/ts/ts-rs/Orientation.ts +6 -0
  198. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  199. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  200. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  201. package/src/ts/ts-rs/TabLayout.ts +11 -0
  202. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  203. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  204. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  205. package/tsconfig.json +1 -1
  206. package/src/rust/components/style/style_cache.rs +0 -136
  207. package/src/rust/tasks/intersection_observer.rs +0 -98
  208. package/src/rust/tasks/update_and_render.rs +0 -110
  209. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
@@ -16,15 +16,18 @@ use std::cell::RefCell;
16
16
  use std::rc::Rc;
17
17
 
18
18
  use futures::channel::oneshot::channel;
19
+ use futures::future::join_all;
19
20
  use js_sys::{Array, JsString};
20
21
  use perspective_client::config::ViewConfigUpdate;
21
22
  use perspective_client::utils::PerspectiveResultExt;
23
+ use perspective_js::utils::global;
22
24
  use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
23
25
  use wasm_bindgen::JsCast;
24
26
  use wasm_bindgen::prelude::*;
25
27
  use wasm_bindgen_derive::try_from_js_option;
26
28
  use wasm_bindgen_futures::JsFuture;
27
29
  use web_sys::HtmlElement;
30
+ use yew::Callback;
28
31
 
29
32
  use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
30
33
  use crate::config::*;
@@ -32,20 +35,59 @@ use crate::custom_events::*;
32
35
  use crate::js::*;
33
36
  use crate::presentation::*;
34
37
  use crate::queries::*;
35
- use crate::renderer::*;
36
38
  use crate::root::Root;
37
- use crate::session::{ResetOptions, Session, TableLoadState};
39
+ use crate::session::{ResetOptions, TableLoadState};
38
40
  use crate::tasks::*;
39
41
  use crate::utils::*;
42
+ use crate::workspace::{Panel, PanelId, Workspace};
40
43
  use crate::*;
41
44
 
42
45
  #[wasm_bindgen]
43
46
  extern "C" {
47
+ /// `load()` argument: a [`Client`], a (deprecated) [`Table`], or a
48
+ /// `Promise` resolving to either. Typed rather than `any` so callers get
49
+ /// completion; the `Table` forms remain runtime-deprecated.
50
+ #[wasm_bindgen(typescript_type = "Client | Table | Promise<Client | Table>")]
51
+ pub type JsClientLoad;
52
+
53
+ /// `eject()` argument dict (`{ client?: string }`).
54
+ #[wasm_bindgen(typescript_type = "ClientOptions")]
55
+ pub type JsClientOptions;
56
+
57
+ /// Panel-selector dict (`{ panel?: string }`) for the active/base
58
+ /// accessor methods.
59
+ #[wasm_bindgen(typescript_type = "PanelOptions")]
60
+ pub type JsPanelOptions;
61
+
62
+ /// `download`/`export`/`copy` options dict
63
+ /// (`{ method?: ExportMethod, panel?: string }`).
64
+ #[wasm_bindgen(typescript_type = "ExportOptions")]
65
+ pub type JsExportOptions;
66
+
67
+ /// `getTable` options dict (`{ wait?: boolean, panel?: string }`).
68
+ #[wasm_bindgen(typescript_type = "GetTableOptions")]
69
+ pub type JsGetTableOptions;
70
+
71
+ /// `getClient` options dict (`{ wait?: boolean, panel?: string }`).
72
+ #[wasm_bindgen(typescript_type = "GetClientOptions")]
73
+ pub type JsGetClientOptions;
74
+
75
+ /// `restoreWorkspace()` argument: a whole-element config update.
76
+ #[wasm_bindgen(typescript_type = "WorkspaceConfigUpdate")]
77
+ pub type JsWorkspaceConfigUpdate;
78
+
79
+ /// `saveWorkspace()` return: a whole-element config.
80
+ #[wasm_bindgen(typescript_type = "Promise<WorkspaceConfig>")]
81
+ pub type JsWorkspaceConfigPromise;
82
+
83
+ /// A `Promise<void>` return, used by the `restore` family (whose
84
+ /// `ApiFuture<()>` would otherwise erase to `Promise<any>`).
85
+ #[wasm_bindgen(typescript_type = "Promise<void>")]
86
+ pub type JsVoidPromise;
87
+
88
+ /// `save()` return: a single-panel config.
44
89
  #[wasm_bindgen(typescript_type = "Promise<ViewerConfig>")]
45
90
  pub type JsViewerConfigPromise;
46
-
47
- #[wasm_bindgen(typescript_type = "ViewerConfigUpdate")]
48
- pub type JsViewerConfigUpdate;
49
91
  }
50
92
 
51
93
  #[derive(serde::Deserialize, Default)]
@@ -59,6 +101,20 @@ struct ResizeDimensions {
59
101
  height: f64,
60
102
  }
61
103
 
104
+ /// Leniently deserialize an optional JS options dict into a serde struct,
105
+ /// falling back to `Default` on absence or a malformed argument (matching the
106
+ /// `ResizeOptions` precedent — an options bag is a best-effort convenience,
107
+ /// not a hard-validated payload).
108
+ fn parse_options<T, U>(options: Option<T>) -> U
109
+ where
110
+ T: Into<JsValue>,
111
+ U: Default + for<'a> serde::Deserialize<'a>,
112
+ {
113
+ options
114
+ .and_then(|o| o.into_serde_ext().ok())
115
+ .unwrap_or_default()
116
+ }
117
+
62
118
  /// The `<perspective-viewer>` custom element.
63
119
  ///
64
120
  /// # JavaScript Examples
@@ -86,13 +142,13 @@ struct ResizeDimensions {
86
142
  #[derive(Clone)]
87
143
  #[wasm_bindgen]
88
144
  pub struct PerspectiveViewerElement {
89
- elem: HtmlElement,
90
- root: Root<components::viewer::PerspectiveViewer>,
91
- resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
92
- intersection_handle: Rc<RefCell<Option<IntersectionObserverHandle>>>,
93
- pub(crate) session: Session,
94
- pub(crate) renderer: Renderer,
95
145
  pub(crate) presentation: Presentation,
146
+ pub(crate) workspace: Workspace,
147
+ pub(crate) elem: HtmlElement,
148
+ pub(crate) root: Root<components::viewer::PerspectiveViewer>,
149
+ resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
150
+ intersection_handle: Rc<RefCell<Option<AutoPauseHandle>>>,
151
+ hosted_table_subs: HostedTableSubs,
96
152
  _subscriptions: Rc<[Subscription; 2]>,
97
153
  _custom_event_subs: Rc<Vec<Subscription>>,
98
154
  }
@@ -103,6 +159,111 @@ impl CustomElementMetadata for PerspectiveViewerElement {
103
159
  ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice();
104
160
  }
105
161
 
162
+ impl PerspectiveViewerElement {
163
+ fn layout_changed_notify(&self) -> Callback<()> {
164
+ let root = self.root.clone();
165
+ Callback::from(move |_: ()| {
166
+ if let Some(app) = root.borrow().as_ref() {
167
+ app.send_message(PerspectiveViewerMsg::LayoutChanged);
168
+ }
169
+ })
170
+ }
171
+
172
+ fn resolve_panel(&self, name: Option<String>) -> ApiResult<Panel> {
173
+ let id = name.map(PanelId::from);
174
+ self.workspace.panel_or_active(id.as_ref()).ok_or_else(|| {
175
+ format!(
176
+ "No panel named \"{}\"",
177
+ id.as_ref().map(PanelId::as_str).unwrap_or_default()
178
+ )
179
+ .into()
180
+ })
181
+ }
182
+
183
+ fn layout_element(&self) -> Option<RegularLayout> {
184
+ self.elem
185
+ .shadow_root()?
186
+ .query_selector(RegularLayout::TAG_NAME)
187
+ .ok()
188
+ .flatten()
189
+ .map(|el| el.unchecked_into())
190
+ }
191
+
192
+ async fn workspace_config(this: Self) -> ApiResult<JsValue> {
193
+ let mut panels: std::collections::BTreeMap<String, PanelViewerConfig> = Default::default();
194
+ for id in &this.workspace.panel_ids() {
195
+ let panel = this.workspace.panel(id).into_apierror()?;
196
+ let config = panel
197
+ .renderer
198
+ .clone()
199
+ .with_lock(async {
200
+ get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
201
+ })
202
+ .await?;
203
+
204
+ panels.insert(id.as_str().to_owned(), config.panel);
205
+ }
206
+
207
+ let active = this
208
+ .presentation
209
+ .is_settings_open()
210
+ .then(|| this.workspace.active_id())
211
+ .flatten()
212
+ .map(|id| id.as_str().to_owned());
213
+
214
+ let layout = this
215
+ .layout_element()
216
+ .map(|l| l.save().into_serde_ext::<crate::js::Layout>())
217
+ .transpose()?;
218
+
219
+ Ok(JsValue::from_serde_ext(&WorkspaceConfig {
220
+ version: API_VERSION.to_string(),
221
+ active,
222
+ layout,
223
+ panels,
224
+ global_filters: this.workspace.global_filters(),
225
+ masters: this
226
+ .workspace
227
+ .masters()
228
+ .iter()
229
+ .map(|id| id.as_str().to_owned())
230
+ .collect(),
231
+ })?)
232
+ }
233
+ }
234
+
235
+ fn eject_client_panels(
236
+ workspace: &Workspace,
237
+ root: &Root<crate::components::viewer::PerspectiveViewer>,
238
+ target: String,
239
+ ids: Vec<PanelId>,
240
+ ) -> ApiFuture<()> {
241
+ clone!(workspace, root);
242
+ let effect = workspace.effects().guard();
243
+ ApiFuture::new_throttled(async move {
244
+ let _effect = effect;
245
+ for id in ids {
246
+ let (completion, receiver) = Completion::new();
247
+ root.borrow()
248
+ .as_ref()
249
+ .into_apierror()?
250
+ .send_message(PerspectiveViewerMsg::ClosePanel(
251
+ id.to_string(),
252
+ Some(completion),
253
+ ));
254
+
255
+ receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
256
+ }
257
+
258
+ workspace.remove_client(&target);
259
+ Ok(())
260
+ })
261
+ }
262
+
263
+ #[rustfmt::skip]
264
+ const DEPRECATED_TABLE_MESSAGE: &str =
265
+ "`load(table)` is deprecated - use `load(client)` followed by `restore({table: \"name\"})` instead";
266
+
106
267
  #[wasm_bindgen]
107
268
  impl PerspectiveViewerElement {
108
269
  #[doc(hidden)]
@@ -118,58 +279,58 @@ impl PerspectiveViewerElement {
118
279
  }
119
280
 
120
281
  fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
121
- // Application State
122
- let session = Session::new();
123
- let renderer = Renderer::new(&elem);
282
+ // Application State.
124
283
  let presentation = Presentation::new(&elem);
125
- let custom_event_subs = wire_custom_events(&elem, &session, &renderer, &presentation);
284
+
285
+ // Boot with ZERO panels — an unconfigured element is a blank stage. The
286
+ // first `load`/`restore`/`addPanel` creates the first panel, which
287
+ // adopts the element's `theme` attribute when set (see
288
+ // `create_panel_model`'s authored-theme boot).
289
+ let workspace = Workspace::new();
290
+ let custom_event_subs = wire_element_events(&elem, &presentation, &workspace);
126
291
 
127
292
  // Create Yew App
128
293
  let props = yew::props!(PerspectiveViewerProps {
129
294
  elem: elem.clone(),
130
- session: session.clone(),
131
- renderer: renderer.clone(),
132
295
  presentation: presentation.clone(),
296
+ workspace: workspace.clone(),
133
297
  });
134
298
 
135
299
  let state = props.clone();
136
300
  let root = Root::new(shadow_root, props);
137
301
 
138
302
  // Create callbacks
139
- let update_sub = session.table_updated.add_listener({
140
- clone!(renderer, session);
303
+ let eject_sub = presentation.on_eject.add_listener({
304
+ let root = root.clone();
141
305
  move |_| {
142
- clone!(renderer, session);
306
+ clone!(state.workspace, root);
143
307
  ApiFuture::spawn(async move {
144
- renderer
145
- .update(session.get_view())
146
- .await
147
- .ignore_view_delete()
148
- .map(|_| ())
308
+ if let Some(target) = workspace.active_client().map(|c| c.get_name().to_owned())
309
+ {
310
+ let ids = workspace.panels_for_client(&target);
311
+ if ids.len() < workspace.panel_ids().len() {
312
+ return eject_client_panels(&workspace, &root, target, ids).await;
313
+ }
314
+ }
315
+
316
+ delete_all(&workspace, &root).await
149
317
  })
150
318
  }
151
319
  });
152
320
 
153
- let eject_sub = presentation.on_eject.add_listener({
154
- let root = root.clone();
155
- move |_| ApiFuture::spawn(delete_all(&state.session, &state.renderer, &root))
156
- });
157
-
158
- let resize_handle =
159
- ResizeObserverHandle::new(&elem, &renderer, &session, &presentation, &root);
160
-
161
- let intersect_handle =
162
- IntersectionObserverHandle::new(&elem, &presentation, &session, &renderer);
321
+ let resize_handle = ResizeObserverHandle::new(&elem, &workspace, &presentation, &root);
322
+ let intersect_handle = AutoPauseHandle::new(&elem, &presentation, &workspace);
323
+ let (lifecycle_sub, hosted_table_subs) = wire_table_lifecycle(&workspace, &presentation);
163
324
 
164
325
  Self {
165
326
  elem,
166
327
  root,
167
- session,
168
- renderer,
169
328
  presentation,
329
+ workspace,
170
330
  resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
171
331
  intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
172
- _subscriptions: Rc::new([update_sub, eject_sub]),
332
+ hosted_table_subs,
333
+ _subscriptions: Rc::new([eject_sub, lifecycle_sub]),
173
334
  _custom_event_subs: Rc::new(custom_event_subs),
174
335
  }
175
336
  }
@@ -255,69 +416,206 @@ impl PerspectiveViewerElement {
255
416
  /// const table = await worker.table("x\n1", {name: "table_one"});
256
417
  /// await viewer.load(table);
257
418
  /// ```
258
- pub fn load(&self, table: JsValue) -> ApiResult<ApiFuture<()>> {
419
+ pub fn load(&self, client: JsClientLoad) -> ApiResult<ApiFuture<()>> {
420
+ let effect = self.workspace.effects().guard();
421
+ let table: JsValue = client.into();
259
422
  let promise = table
260
423
  .clone()
261
424
  .dyn_into::<js_sys::Promise>()
262
425
  .unwrap_or_else(|_| js_sys::Promise::resolve(&table));
263
426
 
264
- let _plugin = self.renderer.get_active_plugin()?;
265
- let reset_task = self.session.reset(ResetOptions {
266
- config: true,
267
- expressions: true,
268
- stats: true,
269
- table: Some(session::TableIntermediateState::Reloaded),
270
- });
271
-
272
- clone!(self.renderer, self.session);
273
- Ok(ApiFuture::new_throttled(async move {
274
- let task = async {
275
- // Ignore this error, which is blown away by the table anyway.
276
- let _ = reset_task.await;
277
- let jstable = JsFuture::from(promise)
278
- .await
279
- .map_err(|x| apierror!(TableError(x)))?;
280
-
281
- if let Ok(Some(table)) =
282
- try_from_js_option::<perspective_js::Table>(jstable.clone())
427
+ // Resolve the target panel. On an EMPTY element (zero panels):
428
+ // - a synchronously-detectable `Client` registers inertly with NO panel (the
429
+ // common `load(client)` — no phantom panel is left behind);
430
+ // - otherwise (a resolved `Table`, or a `Promise` whose type isn't yet known)
431
+ // the first panel is RESERVED synchronously here — a full panel model held
432
+ // in the workspace's reservation slot, NOT placed — so its ordering position
433
+ // is fixed at the call site: a `restore()` fired right after an unawaited
434
+ // `load()` CLAIMS the reservation (placing it) and targets THIS panel, not a
435
+ // second one. The reservation is likewise placed when the payload proves to
436
+ // be a `Table` (or the load fails, surfacing its error), and discarded —
437
+ // only while still unclaimed — for an inert `Client` payload. Placement and
438
+ // discard are both atomic slot transfers (`Workspace::claim_reserved` /
439
+ // `Workspace::take_reserved`), so an inert payload disposing a panel a
440
+ // racing `restore` claimed is unrepresentable.
441
+ // A pre-existing active panel is used as-is (a `Client` registers
442
+ // inertly against it, never clearing its table).
443
+ let (panel, notify) = match self.workspace.active_panel() {
444
+ Some(panel) => (panel, None),
445
+ None => {
446
+ // Empty element — classify the payload synchronously where possible.
447
+ if let Ok(Some(client)) =
448
+ try_from_js_option::<perspective_js::Client>(table.clone())
283
449
  {
284
- let client = table.get_client().await;
285
- session.set_client(client.get_client().clone());
286
- let name = table.get_name().await;
287
- tracing::debug!(
288
- "Loading {:.0} rows from `Table` {}",
289
- table.size().await?,
290
- name
450
+ // A resolved `Client` registers SYNCHRONOUSLY (so an unawaited
451
+ // `load(client)` is visible to a `restore()` fired right after,
452
+ // which creates the first panel and federates against loaded
453
+ // clients) and creates NO panel — inert.
454
+ self.workspace
455
+ .set_default_client(client.get_client().clone());
456
+ return Ok(ApiFuture::new(async { Ok(()) }));
457
+ }
458
+
459
+ // A resolved `Table` (or a `Promise` whose type isn't yet known)
460
+ // adopts the pending reservation (a second `load()` on a
461
+ // still-empty element), else reserves a fresh panel model.
462
+ let panel = self.workspace.reserved_panel().unwrap_or_else(|| {
463
+ create_panel_model(
464
+ &self.elem,
465
+ &self.presentation,
466
+ &self.workspace,
467
+ None,
468
+ ViewerConfigUpdate::default(),
469
+ None,
470
+ Placement::Reserved,
291
471
  );
472
+ self.workspace
473
+ .reserved_panel()
474
+ .expect("just-reserved panel is present")
475
+ });
476
+
477
+ // Carrying `Some(notify)` marks this load as the reservation's
478
+ // owner — the only call that may place or discard it below.
479
+ (panel, Some(self.layout_changed_notify()))
480
+ },
481
+ };
482
+
483
+ // A `Table` payload targets this panel's engines; a `Client` registers
484
+ // inertly against it. Selecting the panel here (not at construction)
485
+ // keeps the registry race safe — by `load()` time real plugins have
486
+ // registered.
487
+ let session = panel.session;
488
+ let renderer = panel.renderer;
489
+
490
+ // Open the pending-load window SYNCHRONOUSLY, at the call site — this
491
+ // is what fixes the ordering. The payload's RESET disposition (a
492
+ // `Table` resets the view; a `Client` does not) is unknown until the
493
+ // promise resolves, but the window's POSITION on the config-commit
494
+ // stream is fixed NOW. A `restore()` a caller fires immediately after
495
+ // this unawaited `load()` (the React prop-binding pattern, which has
496
+ // no async ordering guarantees) commits INTO this window's journal and
497
+ // is replayed over the reset base if the payload proves to be a
498
+ // `Table` — so a moved-async reset can no longer clobber a later
499
+ // commit. See `SESSION_CONFIG_COHERENCE_PLAN.md`.
500
+ let generation = session.begin_pending_load();
501
+
502
+ clone!(self.workspace, self.presentation);
503
+ Ok(ApiFuture::new_throttled(async move {
504
+ let _effect = effect;
505
+ renderer.set_throttle(None);
506
+ let _run_token = session.begin_config_run();
507
+ let result = {
508
+ clone!(session, renderer, workspace, notify);
509
+ renderer
510
+ .clone()
511
+ .render_task(|guard| async move {
512
+ renderer.set_default_theme(presentation.get_default_theme_name().await);
513
+ renderer.stamp_theme(None);
514
+ let jstable = JsFuture::from(promise)
515
+ .await
516
+ .map_err(|x| apierror!(TableError(x)))?;
517
+
518
+ if let Ok(Some(table)) =
519
+ try_from_js_option::<perspective_js::Table>(jstable.clone())
520
+ {
521
+ tracing::warn!("{}", DEPRECATED_TABLE_MESSAGE);
522
+ let Some(journal) = session.take_pending_load(generation) else {
523
+ return Ok(None);
524
+ };
525
+
526
+ if let Some(notify) = &notify {
527
+ place_reserved(&workspace, notify);
528
+ }
292
529
 
293
- if session.set_table(name).await? {
294
- session.validate().await?.create_view().await?;
295
- }
530
+ let _plugin = renderer.ensure_plugin_selected()?;
531
+ let _ = renderer.mount_active_plugin();
532
+ session
533
+ .reset(ResetOptions {
534
+ config: true,
535
+ expressions: true,
536
+ stats: true,
537
+ table: Some(session::TableIntermediateState::Reloaded),
538
+ })
539
+ .await
540
+ .unwrap_or_log();
541
+
542
+ let client = table.get_client().await;
543
+ let inner_client = client.get_client().clone();
544
+ session.set_client(inner_client.clone());
545
+ workspace.set_default_client(inner_client);
546
+ let name = table.get_name().await;
547
+ tracing::debug!(
548
+ "Loading {:.0} rows from `Table` {}",
549
+ table.size().await?,
550
+ name
551
+ );
552
+
553
+ session.set_table(name).await?;
554
+ for delta in journal {
555
+ session.commit_view_config(delta)?;
556
+ }
296
557
 
297
- Ok(session.get_view())
298
- } else if let Ok(Some(client)) =
299
- wasm_bindgen_derive::try_from_js_option::<perspective_js::Client>(jstable)
300
- {
301
- session.set_client(client.get_client().clone());
302
- Ok(session.get_view())
303
- } else {
304
- Err(ApiError::new("Invalid argument"))
305
- }
558
+ session.commit_table_defaults();
559
+ let (disposition, _pin) =
560
+ crate::tasks::bind_snapshot(&guard, &session, &renderer).await?;
561
+
562
+ crate::tasks::dispatch_bound(
563
+ &guard,
564
+ &renderer,
565
+ disposition,
566
+ false,
567
+ crate::tasks::RunOrigin::Public,
568
+ )
569
+ .await?;
570
+
571
+ Ok(None)
572
+ } else if let Ok(Some(client)) = wasm_bindgen_derive::try_from_js_option::<
573
+ perspective_js::Client,
574
+ >(jstable)
575
+ {
576
+ // INERT: register the client only — never rebind or
577
+ // reset the active panel (its table is preserved).
578
+ // Panels bind their client lazily at table-resolution
579
+ // time (`Workspace::resolve_client_for_table`). The
580
+ // window is discarded (not replayed): a `Client`
581
+ // performs no reset, and any racing `restore`'s
582
+ // commits already applied live (`commit_view_config`).
583
+ let owned_window = session.take_pending_load(generation).is_some();
584
+ let discard = if owned_window && notify.is_some() {
585
+ workspace.take_reserved()
586
+ } else {
587
+ None
588
+ };
589
+
590
+ workspace.set_default_client(client.get_client().clone());
591
+ Ok(discard)
592
+ } else {
593
+ session.take_pending_load(generation);
594
+ Err(ApiError::new("Invalid argument"))
595
+ }
596
+ })
597
+ .await
306
598
  };
307
599
 
308
- renderer.set_throttle(None);
309
- let result = renderer.draw(task).await;
310
- if let Err(e) = &result {
311
- session.set_error(false, e.clone()).await?;
312
- }
600
+ match result {
601
+ Err(e) => {
602
+ session.take_pending_load(generation);
603
+ if let Some(notify) = &notify {
604
+ place_reserved(&workspace, notify);
605
+ }
313
606
 
314
- result
607
+ session.set_error(false, e.clone()).await?;
608
+ Err(e)
609
+ },
610
+ Ok(Some(panel)) => eject_panel(panel).await,
611
+ Ok(None) => Ok(()),
612
+ }
315
613
  }))
316
614
  }
317
615
 
318
- /// Delete the internal [`View`] and all associated state, rendering this
616
+ /// Delete all internal [`View`]s and all associated state, rendering this
319
617
  /// `<perspective-viewer>` unusable and freeing all associated resources.
320
- /// Does not delete the supplied [`Table`] (as this is constructed by the
618
+ /// Does not delete any supplied [`Table`] (as this is constructed by the
321
619
  /// callee).
322
620
  ///
323
621
  /// Calling _any_ method on a `<perspective-viewer>` after [`Self::delete`]
@@ -337,27 +635,67 @@ impl PerspectiveViewerElement {
337
635
  /// await viewer.delete();
338
636
  /// ```
339
637
  pub fn delete(self) -> ApiFuture<()> {
340
- delete_all(&self.session, &self.renderer, &self.root)
638
+ let subs = std::mem::take(&mut *self.hosted_table_subs.borrow_mut());
639
+ let teardown = delete_all(&self.workspace, &self.root);
640
+ ApiFuture::new(async move {
641
+ for (client, id) in subs {
642
+ let _ = client.remove_hosted_tables_update(id).await;
643
+ }
644
+
645
+ teardown.await
646
+ })
341
647
  }
342
648
 
343
- /// Restart this `<perspective-viewer>` to its initial state, before
344
- /// `load()`.
649
+ /// Remove a [`Client`] from this `<perspective-viewer>` and dispose every
650
+ /// panel bound to it (each panel's `View` is deleted and its `Table`
651
+ /// reference released).
652
+ ///
653
+ /// # Arguments
345
654
  ///
346
- /// Use `Self::restart` if you plan to call `Self::load` on this viewer
347
- /// again, or alternatively `Self::delete` if this viewer is no longer
348
- /// needed.
349
- pub fn eject(&mut self) -> ApiFuture<()> {
350
- if matches!(self.session.has_table(), Some(TableLoadState::Loaded)) {
655
+ /// - `options` - An optional `{client?: string}` dict naming the client to
656
+ /// eject; the active panel's client when omitted.
657
+ ///
658
+ /// # JavaScript Examples
659
+ ///
660
+ /// ```javascript
661
+ /// await viewer.eject();
662
+ /// await viewer.eject({client: "remote"});
663
+ /// ```
664
+ pub fn eject(&mut self, options: Option<JsClientOptions>) -> ApiFuture<()> {
665
+ let ClientOptions { client } = parse_options(options);
666
+ // Default target: the active panel's client, or — when the active panel
667
+ // is unbound (`load(Client)` is now inert) — the default client.
668
+ let Some(target) = client
669
+ .or_else(|| {
670
+ self.workspace
671
+ .active_client()
672
+ .map(|c| c.get_name().to_owned())
673
+ })
674
+ .or_else(|| {
675
+ self.workspace
676
+ .default_client()
677
+ .map(|c| c.get_name().to_owned())
678
+ })
679
+ else {
680
+ return ApiFuture::new_throttled(async move { Ok(()) });
681
+ };
682
+
683
+ let ids = self.workspace.panels_for_client(&target);
684
+
685
+ // The target client backs EVERY panel — reset the element to its
686
+ // pre-`load` state (dropping the client with it), as a `Workspace`
687
+ // must always keep at least one panel.
688
+ if !ids.is_empty() && ids.len() == self.workspace.panel_ids().len() {
351
689
  let mut state = Self::new_from_shadow(
352
690
  self.elem.clone(),
353
691
  self.elem.shadow_root().unwrap().unchecked_into(),
354
692
  );
355
693
 
356
694
  std::mem::swap(self, &mut state);
357
- ApiFuture::new_throttled(state.delete())
358
- } else {
359
- ApiFuture::new_throttled(async move { Ok(()) })
695
+ return ApiFuture::new_throttled(state.delete());
360
696
  }
697
+
698
+ eject_client_panels(&self.workspace, &self.root, target, ids)
361
699
  }
362
700
 
363
701
  /// Get the underlying [`View`] for this viewer.
@@ -380,9 +718,13 @@ impl PerspectiveViewerElement {
380
718
  /// const view = await viewer.getView();
381
719
  /// ```
382
720
  #[wasm_bindgen]
383
- pub fn getView(&self) -> ApiFuture<View> {
384
- let session = self.session.clone();
385
- ApiFuture::new(async move { Ok(session.get_view().ok_or("No table set")?.into()) })
721
+ pub fn getView(&self, options: Option<JsPanelOptions>) -> ApiFuture<View> {
722
+ let PanelOptions { panel: name } = parse_options(options);
723
+ let this = self.clone();
724
+ ApiFuture::new(async move {
725
+ let panel = this.resolve_panel(name)?;
726
+ Ok(panel.session.get_view().ok_or("No table set")?.into())
727
+ })
386
728
  }
387
729
 
388
730
  /// Get a copy of the [`ViewConfig`] for the current [`View`]. This is
@@ -390,21 +732,18 @@ impl PerspectiveViewerElement {
390
732
  /// [`PerspectiveViewerElement::save`]), and also makes no API calls to the
391
733
  /// server (unlike [`PerspectiveViewerElement::getView`] followed by
392
734
  /// [`View::get_config`])
393
- ///
394
- /// Returns the [`ViewConfig`] the currently-bound `View` was constructed
395
- /// from, so the value is consistent with what the active plugin is
396
- /// rendering even if a queued [`Self::restore`]/`update_and_render` has
397
- /// already mutated the live config in anticipation of the next draw.
398
- /// Falls back to the live session config when no `View` has yet been
399
- /// created (e.g., after `load` but before the first render).
400
735
  #[wasm_bindgen]
401
- pub fn getViewConfig(&self) -> ApiFuture<JsViewConfig> {
402
- let session = self.session.clone();
736
+ pub fn getViewConfig(&self, options: Option<JsPanelOptions>) -> ApiFuture<JsViewConfig> {
737
+ let PanelOptions { panel: name } = parse_options(options);
738
+ let this = self.clone();
403
739
  ApiFuture::new(async move {
404
- let config = if let Some(rendered) = session.get_rendered_view_config() {
740
+ let panel = this.resolve_panel(name)?;
741
+ let config = if let Some(ctx) = panel.renderer.render_context() {
742
+ (*ctx.view_config).clone()
743
+ } else if let Some(rendered) = panel.session.get_rendered_view_config() {
405
744
  (*rendered).clone()
406
745
  } else {
407
- session.get_view_config().clone()
746
+ panel.session.get_view_config().clone()
408
747
  };
409
748
 
410
749
  Ok(JsValue::from_serde_ext(&config)?.unchecked_into())
@@ -427,9 +766,21 @@ impl PerspectiveViewerElement {
427
766
  /// const table = await viewer.getTable();
428
767
  /// ```
429
768
  #[wasm_bindgen]
430
- pub fn getTable(&self, wait_for_table: Option<bool>) -> ApiFuture<Table> {
431
- let session = self.session.clone();
769
+ pub fn getTable(&self, options: Option<JsGetTableOptions>) -> ApiFuture<Table> {
770
+ let GetTableOptions {
771
+ wait: wait_for_table,
772
+ panel: name,
773
+ } = parse_options(options);
774
+ let this = self.clone();
432
775
  ApiFuture::new(async move {
776
+ let panel = this.resolve_panel(name)?;
777
+ if !wait_for_table.unwrap_or_default()
778
+ && let Some(ctx) = panel.renderer.render_context()
779
+ {
780
+ return Ok(ctx.table.clone().into());
781
+ }
782
+
783
+ let session = panel.session;
433
784
  match session.get_table() {
434
785
  Some(table) => Ok(table.into()),
435
786
  None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
@@ -457,9 +808,24 @@ impl PerspectiveViewerElement {
457
808
  /// const client = await viewer.getClient();
458
809
  /// ```
459
810
  #[wasm_bindgen]
460
- pub fn getClient(&self, wait_for_client: Option<bool>) -> ApiFuture<perspective_js::Client> {
461
- let session = self.session.clone();
811
+ pub fn getClient(
812
+ &self,
813
+ options: Option<JsGetClientOptions>,
814
+ ) -> ApiFuture<perspective_js::Client> {
815
+ let GetClientOptions {
816
+ wait: wait_for_client,
817
+ panel: name,
818
+ } = parse_options(options);
819
+ let this = self.clone();
462
820
  ApiFuture::new(async move {
821
+ let panel = this.resolve_panel(name)?;
822
+ if !wait_for_client.unwrap_or_default()
823
+ && let Some(ctx) = panel.renderer.render_context()
824
+ {
825
+ return Ok(ctx.client.clone().into());
826
+ }
827
+
828
+ let session = panel.session;
463
829
  match session.get_client() {
464
830
  Some(client) => Ok(client.into()),
465
831
  None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
@@ -481,9 +847,11 @@ impl PerspectiveViewerElement {
481
847
  /// const {virtual_fps, actual_fps} = await viewer.getRenderStats();
482
848
  /// ```
483
849
  #[wasm_bindgen]
484
- pub fn getRenderStats(&self) -> ApiResult<JsValue> {
850
+ pub fn getRenderStats(&self, options: Option<JsPanelOptions>) -> ApiResult<JsValue> {
851
+ let PanelOptions { panel: name } = parse_options(options);
852
+ let panel = self.resolve_panel(name)?;
485
853
  Ok(JsValue::from_serde_ext(
486
- &self.renderer.render_timer().get_stats(),
854
+ &panel.renderer.render_timer().get_stats(),
487
855
  )?)
488
856
  }
489
857
 
@@ -506,25 +874,62 @@ impl PerspectiveViewerElement {
506
874
  /// await viewer.flush();
507
875
  /// ```
508
876
  pub fn flush(&self) -> ApiFuture<()> {
509
- clone!(self.renderer);
877
+ let workspace = self.workspace.clone();
878
+ let presentation = self.presentation.clone();
510
879
  ApiFuture::new_throttled(async move {
511
- // We must let two AFs pass to guarantee listeners to the DOM state
512
- // have themselves triggered, or else `request_animation_frame`
513
- // may finish before a `ResizeObserver` triggered before is
514
- // notifiedd.
515
- //
516
- // https://github.com/w3c/csswg-drafts/issues/9560
517
- // https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering
518
- request_animation_frame().await;
519
- request_animation_frame().await;
520
- renderer.clone().with_lock(async { Ok(()) }).await?;
521
- renderer.with_lock(async { Ok(()) }).await
880
+ loop {
881
+ workspace.effects().settle().await;
882
+ let panels = workspace
883
+ .reserved_panel()
884
+ .into_iter()
885
+ .chain(workspace.panels())
886
+ .collect::<Vec<_>>();
887
+
888
+ let mut fulfilled = false;
889
+ for panel in &panels {
890
+ panel.renderer.clone().with_lock(async { Ok(()) }).await?;
891
+ panel.renderer.clone().with_lock(async { Ok(()) }).await?;
892
+ panel.session.settle_dispatches().await?;
893
+ if !global::document().hidden()
894
+ && presentation.is_visible()
895
+ && !panel.renderer.is_plugin_activated()?
896
+ && panel.session.get_error().is_none()
897
+ && matches!(panel.session.has_table(), Some(TableLoadState::Loaded))
898
+ {
899
+ set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
900
+ .await?;
901
+ if !panel.renderer.is_plugin_activated()? {
902
+ just_render(&panel.session, &panel.renderer)?.await?;
903
+ }
904
+
905
+ fulfilled = true;
906
+ }
907
+ }
908
+
909
+ if !fulfilled && workspace.effects().is_empty() {
910
+ return Ok(());
911
+ }
912
+ }
522
913
  })
523
914
  }
524
915
 
525
- /// Restores this element from a full/partial
526
- /// [`perspective_js::JsViewConfig`] (this element's user-configurable
527
- /// state, including the `Table` name).
916
+ /// Restore a single panel from a full/partial
917
+ /// [`perspective_js::JsViewConfig`] (its user-configurable state, including
918
+ /// the `Table` name) — the active panel, or a specific panel via the
919
+ /// optional `{panel}` selector.
920
+ ///
921
+ /// If `panel` names no existing panel, a NEW panel is created with that id
922
+ /// and the config restored into it (an upsert), equivalent to
923
+ /// [`Self::addPanel`] but with a caller-chosen id. As with a created panel,
924
+ /// the element-level `settings`/`theme` fields are ignored in that case.
925
+ ///
926
+ /// On an empty element with a pending [`Self::load`] whose payload is not
927
+ /// yet classified, the active-target form (no `panel`) instead claims and
928
+ /// restores into that load's reserved first panel — see [`Self::load`].
929
+ ///
930
+ /// This restores a SINGLE panel; a whole-element config (with a `panels`
931
+ /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` /
932
+ /// `layout` keys are ignored here.
528
933
  ///
529
934
  /// One of the best ways to use [`Self::restore`] is by first configuring
530
935
  /// a `<perspective-viewer>` as you wish, then using either the `Debug`
@@ -535,6 +940,7 @@ impl PerspectiveViewerElement {
535
940
  ///
536
941
  /// - `update` - The config to restore to, as returned by [`Self::save`] in
537
942
  /// either "json", "string" or "arraybuffer" format.
943
+ /// - `name` - The panel to target, or `None` for the active panel.
538
944
  ///
539
945
  /// # JavaScript Examples
540
946
  ///
@@ -551,102 +957,199 @@ impl PerspectiveViewerElement {
551
957
  /// ```javascript
552
958
  /// await viewer.restore({group_by: ["State"]});
553
959
  /// ```
554
- pub fn restore(&self, update: JsViewerConfigUpdate) -> ApiFuture<()> {
960
+ pub fn restore(
961
+ &self,
962
+ update: JsViewerConfigUpdate,
963
+ options: Option<JsPanelOptions>,
964
+ ) -> JsVoidPromise {
965
+ let PanelOptions { panel: name } = parse_options(options);
966
+ let effect = self.workspace.effects().guard();
555
967
  let this = self.clone();
556
- ApiFuture::new_throttled(async move {
557
- let decoded_update = ViewerConfigUpdate::decode(&update)?;
558
- tracing::info!("Restoring {}", decoded_update);
559
- let root = this.root.clone();
560
- let settings = decoded_update.settings.clone();
561
- let (sender, receiver) = channel::<()>();
562
- root.borrow().as_ref().into_apierror()?.send_message(
563
- PerspectiveViewerMsg::ToggleSettingsComplete(settings, sender),
564
- );
565
-
566
- let task = if let OptionalUpdate::Update(_) = &decoded_update.table {
567
- Some(this.session.reset(ResetOptions {
568
- config: true,
569
- expressions: true,
570
- stats: true,
571
- ..ResetOptions::default()
572
- }))
573
- } else {
574
- None
575
- };
576
-
577
- let result = restore_and_render(
578
- &this.session,
579
- &this.renderer,
580
- &this.presentation,
581
- decoded_update.clone(),
582
- {
583
- clone!(this, decoded_update.table);
584
- async move {
585
- if let OptionalUpdate::Update(name) = table {
586
- if let Some(task) = task {
587
- task.await?;
588
- }
968
+ let fut = ApiFuture::new_throttled(async move {
969
+ let _effect = effect;
970
+ let id = name.map(PanelId::from);
971
+ let update = ViewerConfigUpdate::decode(&update)?;
972
+ match this.workspace.panel_or_active(id.as_ref()) {
973
+ // An existing (or the active) panel — update it in place.
974
+ Some(panel) => {
975
+ let active = this.workspace.active_id().as_ref() == Some(&panel.id);
976
+ restore_panel(
977
+ &panel.session,
978
+ &panel.renderer,
979
+ &this.presentation,
980
+ &this.workspace,
981
+ Some(&this.root),
982
+ RestoreMode::Existing { active },
983
+ update,
984
+ )
985
+ .await
986
+ },
987
+ // No existing panel matched. The active-target form
988
+ // (`panel: None`) CLAIMS a pending `load()`'s reserved panel
989
+ // — placing it and restoring into it — so a `restore` fired
990
+ // right after an unawaited `load(promise)` configures the
991
+ // panel that load's payload will bind, per the call-site
992
+ // ordering contract in [`Self::load`]. Named upserts and
993
+ // reservation-less elements create a fresh panel instead,
994
+ // routing through the shared `create_panel`
995
+ // (`RestoreMode::Fresh`) pipeline so the new panel's id is
996
+ // the requested `panel`.
997
+ None => {
998
+ let notify = this.layout_changed_notify();
999
+ let claimed = id
1000
+ .is_none()
1001
+ .then(|| place_reserved(&this.workspace, &notify))
1002
+ .flatten();
1003
+ match claimed {
1004
+ Some(panel) => {
1005
+ restore_panel(
1006
+ &panel.session,
1007
+ &panel.renderer,
1008
+ &this.presentation,
1009
+ &this.workspace,
1010
+ Some(&this.root),
1011
+ RestoreMode::Existing { active: true },
1012
+ update,
1013
+ )
1014
+ .await
1015
+ },
1016
+ None => {
1017
+ create_panel(
1018
+ &this.elem,
1019
+ &this.presentation,
1020
+ &this.workspace,
1021
+ &notify,
1022
+ id,
1023
+ update,
1024
+ None,
1025
+ )
1026
+ .await?;
1027
+ Ok(())
1028
+ },
1029
+ }
1030
+ },
1031
+ }
1032
+ });
589
1033
 
590
- this.session.set_table(name).await?;
591
- this.session
592
- .update_column_defaults(&this.renderer.metadata());
593
- };
1034
+ js_sys::Promise::from(fut).unchecked_into()
1035
+ }
594
1036
 
595
- // Something abnormal in the DOM happened, e.g. the
596
- // element was disconnected while rendering.
597
- receiver.await.unwrap_or_log();
598
- Ok(())
1037
+ /// Restore the ENTIRE element from a whole-element
1038
+ /// [`WorkspaceConfigUpdate`] (`{version, active?, layout, panels, ...}`) —
1039
+ /// the multi-panel counterpart of [`Self::restore`]. Every existing panel
1040
+ /// is replaced by the `panels` entries, and the layout tree + master/detail
1041
+ /// cross-filter state re-applied. Unlike [`Self::restore`], this never
1042
+ /// falls back to the single-panel path.
1043
+ ///
1044
+ /// # JavaScript Examples
1045
+ ///
1046
+ /// ```javascript
1047
+ /// await viewer.restoreWorkspace(await otherViewer.saveWorkspace());
1048
+ /// ```
1049
+ pub fn restoreWorkspace(&self, update: JsWorkspaceConfigUpdate) -> JsVoidPromise {
1050
+ let update: JsViewerConfigUpdate = update.unchecked_into();
1051
+ let effect = self.workspace.effects().guard();
1052
+ let this = self.clone();
1053
+ let fut = ApiFuture::new(async move {
1054
+ let _effect = effect;
1055
+ let (contents, eject_tasks) = sync_update_panels(&this, update)?;
1056
+ let results = join_all(contents.into_iter().map(|(id, session, renderer, config)| {
1057
+ let presentation = this.presentation.clone();
1058
+ let workspace = this.workspace.clone();
1059
+ async move {
1060
+ stamp_global_overlay(&workspace, &id, &session);
1061
+ restore_panel(
1062
+ &session,
1063
+ &renderer,
1064
+ &presentation,
1065
+ &workspace,
1066
+ None,
1067
+ RestoreMode::Fresh,
1068
+ config,
1069
+ )
1070
+ .await?;
1071
+ if workspace.is_master(&id) {
1072
+ set_edit_mode(&session, &renderer, "SELECT_ROW_TREE");
599
1073
  }
600
- },
601
- )
1074
+
1075
+ Ok(())
1076
+ }
1077
+ }))
602
1078
  .await;
603
1079
 
604
- if let Err(e) = &result {
605
- this.session.set_error(false, e.clone()).await?;
606
- }
607
- result
608
- })
1080
+ results.into_iter().collect::<ApiResult<Vec<_>>>()?;
1081
+ join_all(eject_tasks)
1082
+ .await
1083
+ .into_iter()
1084
+ .collect::<ApiResult<Vec<_>>>()?;
1085
+
1086
+ Ok(())
1087
+ });
1088
+
1089
+ js_sys::Promise::from(fut).unchecked_into()
609
1090
  }
610
1091
 
611
1092
  /// If this element is in an _errored_ state, this method will clear it and
612
1093
  /// re-render. Calling this method is equivalent to clicking the error reset
613
1094
  /// button in the UI.
614
1095
  pub fn resetError(&self) -> ApiFuture<()> {
615
- ApiFuture::spawn(self.session.reset(ResetOptions::default()));
616
- let this = self.clone();
1096
+ let Some(panel) = self.workspace.active_panel() else {
1097
+ return ApiFuture::new_throttled(async move { Ok(()) });
1098
+ };
1099
+
1100
+ let reset_effect = self.workspace.effects().guard();
1101
+ let reset_task = panel.session.reset(ResetOptions::default());
1102
+ ApiFuture::spawn(async move {
1103
+ let _effect = reset_effect;
1104
+ reset_task.await
1105
+ });
1106
+
1107
+ let effect = self.workspace.effects().guard();
617
1108
  ApiFuture::new_throttled(async move {
618
- update_and_render(&this.session, &this.renderer, ViewConfigUpdate::default())?.await?;
1109
+ let _effect = effect;
1110
+ apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?;
619
1111
  Ok(())
620
1112
  })
621
1113
  }
622
1114
 
623
- /// Save this element's user-configurable state to a serialized state
624
- /// object, one which can be restored via the [`Self::restore`] method.
1115
+ /// Save a single panel's user-configurable state as a [`ViewerConfig`], one
1116
+ /// which can be restored via [`Self::restore`] — the active panel, or a
1117
+ /// specific panel via the optional `{panel}` selector.
1118
+ ///
1119
+ /// This saves a SINGLE panel; to snapshot the ENTIRE element (every panel +
1120
+ /// layout + cross-filters) use [`Self::saveWorkspace`].
1121
+ ///
1122
+ /// # Arguments
1123
+ ///
1124
+ /// - `options` - An optional `{panel?: string}`; the panel to save, or the
1125
+ /// active panel when omitted.
625
1126
  ///
626
1127
  /// # JavaScript Examples
627
1128
  ///
628
1129
  /// Get the current `group_by` setting:
629
1130
  ///
630
1131
  /// ```javascript
631
- /// const {group_by} = await viewer.restore();
1132
+ /// const {group_by} = await viewer.save();
632
1133
  /// ```
633
1134
  ///
634
1135
  /// Reset workflow attached to an external button `myResetButton`:
635
1136
  ///
636
1137
  /// ```javascript
637
1138
  /// const token = await viewer.save();
638
- /// myResetButton.addEventListener("clien", async () => {
1139
+ /// myResetButton.addEventListener("click", async () => {
639
1140
  /// await viewer.restore(token);
640
1141
  /// });
641
1142
  /// ```
642
- pub fn save(&self) -> JsViewerConfigPromise {
1143
+ pub fn save(&self, options: Option<JsPanelOptions>) -> JsViewerConfigPromise {
1144
+ let PanelOptions { panel: name } = parse_options(options);
643
1145
  let this = self.clone();
644
1146
  let fut = ApiFuture::new(async move {
645
- let viewer_config = this
1147
+ let panel = this.resolve_panel(name)?;
1148
+ let viewer_config = panel
646
1149
  .renderer
647
1150
  .clone()
648
1151
  .with_lock(async {
649
- get_viewer_config(&this.session, &this.renderer, &this.presentation).await
1152
+ get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
650
1153
  })
651
1154
  .await?;
652
1155
 
@@ -656,6 +1159,24 @@ impl PerspectiveViewerElement {
656
1159
  js_sys::Promise::from(fut).unchecked_into()
657
1160
  }
658
1161
 
1162
+ /// Save the ENTIRE element to a whole-element [`WorkspaceConfig`]
1163
+ /// (`{version, active?, layout, panels, ...}`) — the multi-panel
1164
+ /// counterpart of [`Self::save`]. Unlike [`Self::save`] (which emits a
1165
+ /// single `ViewerConfig` for one panel), this ALWAYS emits the
1166
+ /// whole-element format, restorable via [`Self::restoreWorkspace`].
1167
+ ///
1168
+ /// # JavaScript Examples
1169
+ ///
1170
+ /// ```javascript
1171
+ /// const token = await viewer.saveWorkspace();
1172
+ /// await viewer.restoreWorkspace(token);
1173
+ /// ```
1174
+ pub fn saveWorkspace(&self) -> JsWorkspaceConfigPromise {
1175
+ let this = self.clone();
1176
+ let fut = ApiFuture::new(Self::workspace_config(this));
1177
+ js_sys::Promise::from(fut).unchecked_into()
1178
+ }
1179
+
659
1180
  /// Download this viewer's internal [`View`] data via a browser download
660
1181
  /// event.
661
1182
  ///
@@ -670,7 +1191,12 @@ impl PerspectiveViewerElement {
670
1191
  /// await viewer.download();
671
1192
  /// })
672
1193
  /// ```
673
- pub fn download(&self, method: Option<JsString>) -> ApiFuture<()> {
1194
+ pub fn download(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1195
+ let ExportOptions {
1196
+ method,
1197
+ panel: name,
1198
+ } = parse_options(options);
1199
+ let method = method.map(|m| JsString::from(m.as_str()));
674
1200
  let this = self.clone();
675
1201
  ApiFuture::new_throttled(async move {
676
1202
  let method = if let Some(method) = method
@@ -682,10 +1208,11 @@ impl PerspectiveViewerElement {
682
1208
  ExportMethod::Csv
683
1209
  };
684
1210
 
1211
+ let panel = this.resolve_panel(name)?;
685
1212
  let blob =
686
- export_method_to_blob(&this.session, &this.renderer, &this.presentation, method)
1213
+ export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method)
687
1214
  .await?;
688
- let is_chart = this.renderer.is_chart();
1215
+ let is_chart = panel.renderer.is_chart();
689
1216
  download(
690
1217
  format!("untitled{}", method.as_filename(is_chart)).as_ref(),
691
1218
  &blob,
@@ -711,7 +1238,12 @@ impl PerspectiveViewerElement {
711
1238
  /// ```javascript
712
1239
  /// const data = await viewer.export("plugin");
713
1240
  /// ```
714
- pub fn export(&self, method: Option<JsString>) -> ApiFuture<JsValue> {
1241
+ pub fn export(&self, options: Option<JsExportOptions>) -> ApiFuture<JsValue> {
1242
+ let ExportOptions {
1243
+ method,
1244
+ panel: name,
1245
+ } = parse_options(options);
1246
+ let method = method.map(|m| JsString::from(m.as_str()));
715
1247
  let this = self.clone();
716
1248
  ApiFuture::new(async move {
717
1249
  let method = if let Some(method) = method
@@ -723,7 +1255,8 @@ impl PerspectiveViewerElement {
723
1255
  ExportMethod::Csv
724
1256
  };
725
1257
 
726
- export_method_to_jsvalue(&this.session, &this.renderer, &this.presentation, method)
1258
+ let panel = this.resolve_panel(name)?;
1259
+ export_method_to_jsvalue(&panel.session, &panel.renderer, &this.presentation, method)
727
1260
  .await
728
1261
  })
729
1262
  }
@@ -743,7 +1276,12 @@ impl PerspectiveViewerElement {
743
1276
  /// await viewer.copy();
744
1277
  /// })
745
1278
  /// ```
746
- pub fn copy(&self, method: Option<JsString>) -> ApiFuture<()> {
1279
+ pub fn copy(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1280
+ let ExportOptions {
1281
+ method,
1282
+ panel: name,
1283
+ } = parse_options(options);
1284
+ let method = method.map(|m| JsString::from(m.as_str()));
747
1285
  let this = self.clone();
748
1286
  ApiFuture::new_throttled(async move {
749
1287
  let method = if let Some(method) = method
@@ -755,36 +1293,64 @@ impl PerspectiveViewerElement {
755
1293
  ExportMethod::Csv
756
1294
  };
757
1295
 
1296
+ let panel = this.resolve_panel(name)?;
758
1297
  let js_task =
759
- export_method_to_blob(&this.session, &this.renderer, &this.presentation, method);
1298
+ export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method);
760
1299
  copy_to_clipboard(js_task, MimeType::TextPlain).await
761
1300
  })
762
1301
  }
763
1302
 
764
- /// Reset the viewer's `ViewerConfig` to the default.
1303
+ /// Reset a panel's `ViewerConfig` to its data-relative default.
1304
+ ///
1305
+ /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the
1306
+ /// cross-filter overlay cleared (symmetric with whole-element
1307
+ /// [`Self::save`] / [`Self::restore`]). With `{panel}`, only that panel is
1308
+ /// reset — the other panels and the overlay are left untouched.
765
1309
  ///
766
1310
  /// # Arguments
767
1311
  ///
768
1312
  /// - `reset_all` - If set, will clear expressions and column settings as
769
1313
  /// well.
1314
+ /// - `options` - An optional `{panel?: string}`; the panel to reset, or
1315
+ /// every panel when omitted.
770
1316
  ///
771
1317
  /// # JavaScript Examples
772
1318
  ///
773
1319
  /// ```javascript
774
- /// await viewer.reset();
1320
+ /// await viewer.reset(); // every panel
1321
+ /// await viewer.reset(true, {panel: "p1"}); // just "p1", + expressions
775
1322
  /// ```
776
- pub fn reset(&self, reset_all: Option<bool>) -> ApiFuture<()> {
777
- tracing::debug!("Resetting config");
778
- let root = self.root.clone();
1323
+ pub fn reset(&self, reset_all: Option<bool>, options: Option<JsPanelOptions>) -> ApiFuture<()> {
1324
+ let PanelOptions { panel: name } = parse_options(options);
1325
+ let effect = self.workspace.effects().guard();
1326
+ let this = self.clone();
779
1327
  let all = reset_all.unwrap_or_default();
780
1328
  ApiFuture::new_throttled(async move {
781
- let (sender, receiver) = channel::<()>();
782
- root.borrow()
783
- .as_ref()
784
- .ok_or("Already deleted")?
785
- .send_message(PerspectiveViewerMsg::Reset(all, Some(sender)));
1329
+ let _effect = effect;
1330
+ let (completion, receiver) = Completion::new();
1331
+ {
1332
+ let root = this.root.borrow();
1333
+ let app = root.as_ref().ok_or("Already deleted")?;
1334
+ match name {
1335
+ // Element-level: reset every panel + the cross-filter overlay.
1336
+ None => {
1337
+ tracing::debug!("Resetting config");
1338
+ app.send_message(PerspectiveViewerMsg::Reset(all, Some(completion)));
1339
+ },
1340
+ // A single named panel; errors if the panel doesn't exist.
1341
+ Some(name) => {
1342
+ let panel = this.resolve_panel(Some(name))?;
1343
+ tracing::debug!("Resetting config ({})", panel.id);
1344
+ app.send_message(PerspectiveViewerMsg::ResetPanel(
1345
+ Some(panel.id.to_string()),
1346
+ all,
1347
+ Some(completion),
1348
+ ));
1349
+ },
1350
+ }
1351
+ }
786
1352
 
787
- Ok(receiver.await?)
1353
+ receiver.await.map_err(|_| ApiError::new("Cancelled"))?
788
1354
  })
789
1355
  }
790
1356
 
@@ -817,18 +1383,27 @@ impl PerspectiveViewerElement {
817
1383
  .unwrap_or_default()
818
1384
  .unwrap_or_default();
819
1385
 
820
- let state = self.clone();
1386
+ let effect = self.workspace.effects().guard();
1387
+ let workspace = self.workspace.clone();
821
1388
  ApiFuture::new_throttled(async move {
822
- if !state.renderer.is_plugin_activated()? {
823
- update_and_render(&state.session, &state.renderer, ViewConfigUpdate::default())?
1389
+ let _effect = effect;
1390
+ // With zero panels there is nothing to resize; fan out to whatever
1391
+ // panels exist otherwise.
1392
+ let Some(panel) = workspace.active_panel() else {
1393
+ resize_visible_panels(&workspace).await;
1394
+ return Ok(());
1395
+ };
1396
+
1397
+ if !panel.renderer.is_plugin_activated()? {
1398
+ apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?
824
1399
  .await?;
825
1400
  } else if let Some(dims) = opts.dimensions {
826
- state
1401
+ panel
827
1402
  .renderer
828
1403
  .resize_with_dimensions(dims.width, dims.height)
829
1404
  .await?;
830
1405
  } else {
831
- state.renderer.resize().await?;
1406
+ resize_visible_panels(&workspace).await;
832
1407
  }
833
1408
 
834
1409
  Ok(())
@@ -860,8 +1435,7 @@ impl PerspectiveViewerElement {
860
1435
  if autosize {
861
1436
  let handle = Some(ResizeObserverHandle::new(
862
1437
  &self.elem,
863
- &self.renderer,
864
- &self.session,
1438
+ &self.workspace,
865
1439
  &self.presentation,
866
1440
  &self.root,
867
1441
  ));
@@ -873,10 +1447,11 @@ impl PerspectiveViewerElement {
873
1447
 
874
1448
  /// Sets the auto-pause behavior of this component.
875
1449
  ///
876
- /// When `true`, this `<perspective-viewer>` will register an
877
- /// `IntersectionObserver` on itself and subsequently skip rendering
878
- /// whenever its viewport visibility changes. Auto-pause is enabled by
879
- /// default.
1450
+ /// When `true`, this `<perspective-viewer>` will skip rendering
1451
+ /// whenever it cannot be seen — tracked via an `IntersectionObserver`
1452
+ /// on itself (scrolled out of the viewport, `display: none`) combined
1453
+ /// with the document's page visibility (backgrounded browser tab,
1454
+ /// minimized window). Auto-pause is enabled by default.
880
1455
  ///
881
1456
  /// # Arguments
882
1457
  ///
@@ -892,53 +1467,75 @@ impl PerspectiveViewerElement {
892
1467
  #[wasm_bindgen]
893
1468
  pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
894
1469
  if autopause {
895
- let handle = Some(IntersectionObserverHandle::new(
1470
+ let handle = Some(AutoPauseHandle::new(
896
1471
  &self.elem,
897
1472
  &self.presentation,
898
- &self.session,
899
- &self.renderer,
1473
+ &self.workspace,
900
1474
  ));
901
1475
 
902
1476
  *self.intersection_handle.borrow_mut() = handle;
903
1477
  } else {
904
1478
  *self.intersection_handle.borrow_mut() = None;
905
- if self.session.set_pause(false) {
906
- return ApiFuture::new(restore_and_render(
907
- &self.session,
908
- &self.renderer,
909
- &self.presentation,
910
- ViewerConfigUpdate::default(),
911
- async move { Ok(()) },
912
- ));
913
- }
1479
+ let effect = self.workspace.effects().guard();
1480
+ let workspace = self.workspace.clone();
1481
+ let presentation = self.presentation.clone();
1482
+ return ApiFuture::new(async move {
1483
+ let _effect = effect;
1484
+ for id in workspace.panel_ids() {
1485
+ if let Some(panel) = workspace.panel(&id) {
1486
+ // A failed resume is already surfaced as that
1487
+ // panel's error state — don't let it abort the
1488
+ // remaining panels' resumes.
1489
+ let _ =
1490
+ set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1491
+ .await;
1492
+ }
1493
+ }
1494
+
1495
+ Ok(())
1496
+ });
914
1497
  }
915
1498
 
916
1499
  ApiFuture::new(async move { Ok(()) })
917
1500
  }
918
1501
 
919
1502
  /// Return a [`perspective_js::JsViewWindow`] for the currently selected
920
- /// region.
1503
+ /// region of the named panel, or the active panel when `panel` is omitted.
921
1504
  #[wasm_bindgen]
922
- pub fn getSelection(&self) -> Option<JsViewWindow> {
923
- self.renderer.get_selection().map(|x| x.into())
1505
+ pub fn getSelection(&self, options: Option<JsPanelOptions>) -> ApiResult<Option<JsViewWindow>> {
1506
+ let PanelOptions { panel: name } = parse_options(options);
1507
+ let panel = self.resolve_panel(name)?;
1508
+ Ok(panel.renderer.get_selection().map(|x| x.into()))
924
1509
  }
925
1510
 
926
- /// Set the selection [`perspective_js::JsViewWindow`] for this element.
1511
+ /// Set the selection [`perspective_js::JsViewWindow`] for the named panel,
1512
+ /// or the active panel when `panel` is omitted.
927
1513
  #[wasm_bindgen]
928
- pub fn setSelection(&self, window: Option<JsViewWindow>) -> ApiResult<()> {
1514
+ pub fn setSelection(
1515
+ &self,
1516
+ window: Option<JsViewWindow>,
1517
+ options: Option<JsPanelOptions>,
1518
+ ) -> ApiResult<()> {
1519
+ let PanelOptions { panel: name } = parse_options(options);
929
1520
  let window = window.map(|x| x.into_serde_ext()).transpose()?;
930
- self.renderer.set_selection(window);
1521
+ self.resolve_panel(name)?.renderer.set_selection(window);
931
1522
  Ok(())
932
1523
  }
933
1524
 
934
- /// Get this viewer's edit port for the currently loaded [`Table`] (see
935
- /// [`Table::update`] for details on ports).
1525
+ /// Get this viewer's edit port for the named panel's [`Table`] (see
1526
+ /// [`Table::update`] for details on ports), or the active panel when
1527
+ /// `panel` is omitted.
936
1528
  #[wasm_bindgen]
937
- pub fn getEditPort(&self) -> Result<f64, JsValue> {
938
- self.session
939
- .metadata()
940
- .get_edit_port()
941
- .ok_or_else(|| "No `Table` loaded".into())
1529
+ pub fn getEditPort(&self, options: Option<JsPanelOptions>) -> ApiResult<f64> {
1530
+ let PanelOptions { panel: name } = parse_options(options);
1531
+ let panel = self.resolve_panel(name)?;
1532
+ let edit_port = if let Some(ctx) = panel.renderer.render_context() {
1533
+ ctx.edit_port
1534
+ } else {
1535
+ panel.session.metadata().get_edit_port()
1536
+ };
1537
+
1538
+ Ok(edit_port.ok_or("No `Table` loaded")?)
942
1539
  }
943
1540
 
944
1541
  /// Restyle all plugins from current document.
@@ -959,10 +1556,21 @@ impl PerspectiveViewerElement {
959
1556
  /// ```
960
1557
  #[wasm_bindgen]
961
1558
  pub fn restyleElement(&self) -> ApiFuture<JsValue> {
962
- clone!(self.renderer, self.session);
1559
+ clone!(self.workspace, self.presentation);
1560
+ let effect = workspace.effects().guard();
963
1561
  ApiFuture::new(async move {
964
- let view = session.get_view().into_apierror()?;
965
- renderer.restyle_all(&view).await
1562
+ let _effect = effect;
1563
+ let default = presentation.get_default_theme_name().await;
1564
+ for panel in workspace
1565
+ .panel_ids()
1566
+ .into_iter()
1567
+ .filter_map(|id| workspace.panel(&id))
1568
+ {
1569
+ panel.renderer.set_default_theme(default.clone());
1570
+ panel.renderer.restyle_all().await?;
1571
+ }
1572
+
1573
+ Ok(JsValue::UNDEFINED)
966
1574
  })
967
1575
  }
968
1576
 
@@ -982,8 +1590,10 @@ impl PerspectiveViewerElement {
982
1590
  /// ```
983
1591
  #[wasm_bindgen]
984
1592
  pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
985
- clone!(self.renderer, self.session, self.presentation);
1593
+ clone!(self.workspace, self.presentation);
1594
+ let effect = workspace.effects().guard();
986
1595
  ApiFuture::new(async move {
1596
+ let _effect = effect;
987
1597
  let themes: Option<Vec<String>> = themes
988
1598
  .unwrap_or_default()
989
1599
  .iter()
@@ -991,17 +1601,37 @@ impl PerspectiveViewerElement {
991
1601
  .collect();
992
1602
 
993
1603
  let theme_name = presentation.get_selected_theme_name().await;
994
- let mut changed = presentation.reset_available_themes(themes).await;
995
- let reset_theme = presentation
996
- .get_available_themes()
997
- .await?
1604
+ presentation.reset_available_themes(themes).await;
1605
+ let available = presentation.get_available_themes().await?;
1606
+ let reset_theme = available
998
1607
  .iter()
999
1608
  .find(|y| theme_name.as_ref() == Some(y))
1000
1609
  .cloned();
1001
1610
 
1002
- changed = presentation.set_theme_name(reset_theme.as_deref()).await? || changed;
1003
- if changed && let Some(view) = session.get_view() {
1004
- return renderer.restyle_all(&view).await;
1611
+ presentation.set_theme_name(reset_theme.as_deref()).await?;
1612
+ let new_default = presentation.get_default_theme_name().await;
1613
+ for panel in workspace
1614
+ .panel_ids()
1615
+ .into_iter()
1616
+ .filter_map(|id| workspace.panel(&id))
1617
+ {
1618
+ // Availability applies to PANELS too: a panel pinned to a
1619
+ // theme outside the new set follows the host — clear the pin
1620
+ // so it renders (and `save`s) the new registry default, the
1621
+ // same keep-if-available-else-default rule applied to the
1622
+ // host selection above.
1623
+ if panel
1624
+ .renderer
1625
+ .theme()
1626
+ .is_some_and(|t| !available.contains(&t))
1627
+ {
1628
+ panel.renderer.set_theme(None);
1629
+ }
1630
+
1631
+ panel.renderer.set_default_theme(new_default.clone());
1632
+ if panel.renderer.needs_restyle() {
1633
+ panel.renderer.restyle_all().await?;
1634
+ }
1005
1635
  }
1006
1636
 
1007
1637
  Ok(JsValue::UNDEFINED)
@@ -1027,7 +1657,14 @@ impl PerspectiveViewerElement {
1027
1657
  /// ```
1028
1658
  #[wasm_bindgen]
1029
1659
  pub fn setThrottle(&self, val: Option<f64>) {
1030
- self.renderer.set_throttle(val);
1660
+ for panel in self
1661
+ .workspace
1662
+ .panel_ids()
1663
+ .into_iter()
1664
+ .filter_map(|id| self.workspace.panel(&id))
1665
+ {
1666
+ panel.renderer.set_throttle(val);
1667
+ }
1031
1668
  }
1032
1669
 
1033
1670
  /// Toggle (or force) the config panel open/closed.
@@ -1044,8 +1681,10 @@ impl PerspectiveViewerElement {
1044
1681
  /// ```
1045
1682
  #[wasm_bindgen]
1046
1683
  pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
1684
+ let effect = self.workspace.effects().guard();
1047
1685
  let root = self.root.clone();
1048
1686
  ApiFuture::new(async move {
1687
+ let _effect = effect;
1049
1688
  let force = force.map(SettingsUpdate::Update);
1050
1689
  let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1051
1690
  root.borrow().as_ref().into_apierror()?.send_message(
@@ -1061,7 +1700,10 @@ impl PerspectiveViewerElement {
1061
1700
  /// [`registerPlugin`] after the host has rendered for the first time.
1062
1701
  #[wasm_bindgen]
1063
1702
  pub fn getAllPlugins(&self) -> Array {
1064
- self.renderer.get_all_plugins().iter().collect::<Array>()
1703
+ self.workspace
1704
+ .active_renderer()
1705
+ .map(|r| r.get_all_plugins().iter().collect::<Array>())
1706
+ .unwrap_or_default()
1065
1707
  }
1066
1708
 
1067
1709
  /// Gets a plugin Custom Element with the `name` field, or get the active
@@ -1073,12 +1715,108 @@ impl PerspectiveViewerElement {
1073
1715
  /// or `None` for the active plugin's Custom Element.
1074
1716
  #[wasm_bindgen]
1075
1717
  pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
1718
+ let renderer = self
1719
+ .workspace
1720
+ .active_renderer()
1721
+ .ok_or_else(|| ApiError::new("No active panel"))?;
1076
1722
  match name {
1077
- None => self.renderer.get_active_plugin(),
1078
- Some(name) => self.renderer.get_plugin(&name),
1723
+ None => renderer.ensure_plugin_selected(),
1724
+ Some(name) => renderer.get_plugin(&name),
1079
1725
  }
1080
1726
  }
1081
1727
 
1728
+ /// Add a new, independent panel to this viewer's layout, rendering the
1729
+ /// supplied [`ViewerConfigUpdate`] into it. The panel uses the default
1730
+ /// [`perspective_client::Client`] (the first passed to [`Self::load`]) to
1731
+ /// resolve its `table`. Returns the generated panel id.
1732
+ ///
1733
+ /// Element-level config fields (`settings`, `theme`) in the argument are
1734
+ /// ignored — those are shared across the element, not per-panel.
1735
+ #[wasm_bindgen]
1736
+ pub fn addPanel(&self, update: JsViewerConfigUpdate) -> ApiFuture<JsValue> {
1737
+ clone!(self.elem, self.presentation, self.workspace);
1738
+ let effect = workspace.effects().guard();
1739
+ let notify = self.layout_changed_notify();
1740
+ ApiFuture::new(async move {
1741
+ let _effect = effect;
1742
+ let update = ViewerConfigUpdate::decode(&update)?;
1743
+ let id = create_panel(
1744
+ &elem,
1745
+ &presentation,
1746
+ &workspace,
1747
+ &notify,
1748
+ None,
1749
+ update,
1750
+ None,
1751
+ )
1752
+ .await?;
1753
+ Ok(JsValue::from_str(id.as_str()))
1754
+ })
1755
+ }
1756
+
1757
+ /// Get the ids of all panels in this viewer's layout, in insertion order.
1758
+ #[wasm_bindgen]
1759
+ pub fn getPanelNames(&self) -> Array {
1760
+ self.workspace
1761
+ .panel_ids()
1762
+ .iter()
1763
+ .map(|id| JsValue::from_str(id.as_str()))
1764
+ .collect()
1765
+ }
1766
+
1767
+ /// The id of the active panel — the one the settings panel and status-bar
1768
+ /// toolbar target — or `null` when the element has zero panels.
1769
+ #[wasm_bindgen]
1770
+ pub fn getActivePanel(&self) -> JsValue {
1771
+ self.workspace
1772
+ .active_id()
1773
+ .map(|id| JsValue::from_str(id.as_str()))
1774
+ .unwrap_or(JsValue::NULL)
1775
+ }
1776
+
1777
+ /// Make the panel with id `name` the active panel, re-targeting the
1778
+ /// settings panel and status-bar toolbar (and the root's
1779
+ /// session/renderer subscriptions) to its engines. Resolves after the
1780
+ /// activation-chrome redraws on both sides of the switch have completed
1781
+ /// (invariant I6).
1782
+ #[wasm_bindgen]
1783
+ pub fn setActivePanel(&self, name: String) -> ApiFuture<()> {
1784
+ let effect = self.workspace.effects().guard();
1785
+ let root = self.root.clone();
1786
+ ApiFuture::new(async move {
1787
+ let _effect = effect;
1788
+ let (completion, receiver) = Completion::new();
1789
+ root.borrow()
1790
+ .as_ref()
1791
+ .into_apierror()?
1792
+ .send_message(PerspectiveViewerMsg::SetActivePanel(name, Some(completion)));
1793
+
1794
+ receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1795
+ })
1796
+ }
1797
+
1798
+ /// Remove the panel with id `name` from the layout, disposing its engines
1799
+ /// (its `View` is deleted and its `Table` reference released). The last
1800
+ /// remaining panel cannot be removed (resolves as a no-op). Resolves
1801
+ /// after the panel's teardown run completes, carrying any teardown
1802
+ /// error — previously fire-and-forget and silently dropped (invariant
1803
+ /// I6). See also [`Self::addPanel`].
1804
+ #[wasm_bindgen]
1805
+ pub fn removePanel(&self, name: String) -> ApiFuture<()> {
1806
+ let effect = self.workspace.effects().guard();
1807
+ let root = self.root.clone();
1808
+ ApiFuture::new(async move {
1809
+ let _effect = effect;
1810
+ let (completion, receiver) = Completion::new();
1811
+ root.borrow()
1812
+ .as_ref()
1813
+ .into_apierror()?
1814
+ .send_message(PerspectiveViewerMsg::ClosePanel(name, Some(completion)));
1815
+
1816
+ receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1817
+ })
1818
+ }
1819
+
1082
1820
  /// Create a new JavaScript Heap reference for this model instance.
1083
1821
  #[doc(hidden)]
1084
1822
  #[allow(clippy::use_self)]
@@ -1094,40 +1832,31 @@ impl PerspectiveViewerElement {
1094
1832
  /// string}`. The CustomEvent is also fired whenever the user toggles the
1095
1833
  /// sidebar manually.
1096
1834
  #[wasm_bindgen]
1097
- pub fn toggleColumnSettings(&self, column_name: String) -> ApiFuture<()> {
1098
- clone!(self.session, self.root);
1099
- ApiFuture::new_throttled(async move {
1100
- let locator = get_column_locator(&session.metadata(), Some(column_name));
1101
- let (sender, receiver) = channel::<()>();
1102
- root.borrow().as_ref().into_apierror()?.send_message(
1103
- PerspectiveViewerMsg::OpenColumnSettings {
1104
- locator,
1105
- sender: Some(sender),
1106
- toggle: true,
1107
- },
1108
- );
1109
-
1110
- receiver.await.map_err(|_| ApiError::from("Cancelled"))
1111
- })
1112
- }
1113
-
1114
- /// Force open the settings for a particular column. Pass `null` to close
1115
- /// the column settings panel. See [`Self::toggleColumnSettings`] for more.
1116
- #[wasm_bindgen]
1117
- pub fn openColumnSettings(
1835
+ pub fn toggleColumnSettings(
1118
1836
  &self,
1119
- column_name: Option<String>,
1120
- toggle: Option<bool>,
1837
+ column_name: String,
1838
+ options: Option<JsPanelOptions>,
1121
1839
  ) -> ApiFuture<()> {
1122
- let locator = get_column_locator(&self.session.metadata(), column_name);
1123
- clone!(self.root);
1840
+ let PanelOptions { panel: name } = parse_options(options);
1841
+ let effect = self.workspace.effects().guard();
1842
+ let this = self.clone();
1124
1843
  ApiFuture::new_throttled(async move {
1844
+ let _effect = effect;
1845
+ let panel = this.resolve_panel(name)?;
1846
+ let was_active = this.workspace.active_id().as_ref() == Some(&panel.id);
1847
+ let locator = get_column_locator(&panel.session.metadata(), Some(column_name));
1848
+ if !was_active {
1849
+ this.root.borrow().as_ref().into_apierror()?.send_message(
1850
+ PerspectiveViewerMsg::SetActivePanel(panel.id.as_str().to_owned(), None),
1851
+ );
1852
+ }
1853
+
1125
1854
  let (sender, receiver) = channel::<()>();
1126
- root.borrow().as_ref().into_apierror()?.send_message(
1855
+ this.root.borrow().as_ref().into_apierror()?.send_message(
1127
1856
  PerspectiveViewerMsg::OpenColumnSettings {
1128
1857
  locator,
1129
1858
  sender: Some(sender),
1130
- toggle: toggle.unwrap_or_default(),
1859
+ toggle: was_active,
1131
1860
  },
1132
1861
  );
1133
1862