@perspective-dev/viewer 4.5.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) 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/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/extensions.d.ts +22 -3
  24. package/dist/esm/perspective-viewer.d.ts +9 -0
  25. package/dist/esm/perspective-viewer.inline.js +20 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +20 -1
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/plugin.d.ts +71 -5
  30. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  32. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  33. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  34. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  35. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  36. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  37. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  38. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -0
  39. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  40. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  41. package/dist/esm/ts-rs/ViewerConfig.d.ts +8 -2
  42. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  43. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  44. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  45. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  46. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  47. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  50. package/dist/wasm/perspective-viewer.d.ts +299 -78
  51. package/dist/wasm/perspective-viewer.js +544 -186
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  54. package/dist/wasm/snippets/perspective-viewer-8a8c7d5d696b43ae/inline0.js +6 -0
  55. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-8a8c7d5d696b43ae/inline4.js} +5 -0
  56. package/package.json +3 -2
  57. package/src/css/_context_menu.css +14 -0
  58. package/src/css/column-selector.css +1 -0
  59. package/src/css/column-settings-panel.css +68 -3
  60. package/src/css/column-style.css +4 -3
  61. package/src/css/config-selector.css +22 -1
  62. package/src/css/containers/context-menu.css +107 -0
  63. package/src/css/containers/tabs.css +2 -2
  64. package/src/css/panel-tab.css +193 -0
  65. package/src/css/plugin-selector.css +7 -10
  66. package/src/css/status-bar.css +75 -81
  67. package/src/css/viewer.css +188 -70
  68. package/src/rust/components/column_dropdown.rs +40 -30
  69. package/src/rust/components/column_selector/active_column.rs +28 -45
  70. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  71. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  72. package/src/rust/components/column_selector/config_selector.rs +40 -82
  73. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  74. package/src/rust/components/column_selector/filter_column.rs +5 -15
  75. package/src/rust/components/column_selector/inactive_column.rs +11 -9
  76. package/src/rust/components/column_selector/sort_column.rs +3 -6
  77. package/src/rust/components/column_selector.rs +25 -14
  78. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  79. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  80. package/src/rust/components/column_settings_sidebar.rs +163 -16
  81. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  82. package/src/rust/components/containers/sidebar.rs +99 -22
  83. package/src/rust/components/containers/split_panel.rs +181 -62
  84. package/src/rust/components/context_menu.rs +130 -0
  85. package/src/rust/components/editable_header.rs +2 -0
  86. package/src/rust/components/form/debug.rs +3 -0
  87. package/src/rust/components/global_filter_bar.rs +76 -0
  88. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  89. package/src/rust/components/main_panel/msg.rs +62 -0
  90. package/src/rust/components/main_panel/presize.rs +220 -0
  91. package/src/rust/components/main_panel/reconcile.rs +224 -0
  92. package/src/rust/components/main_panel/render.rs +385 -0
  93. package/src/rust/components/main_panel/update.rs +184 -0
  94. package/src/rust/components/main_panel.rs +316 -71
  95. package/src/rust/components/mod.rs +5 -0
  96. package/src/rust/components/panel_menu.rs +493 -0
  97. package/src/rust/components/panel_tab.rs +510 -0
  98. package/src/rust/components/plugin_tab.rs +1 -1
  99. package/src/rust/components/portal.rs +7 -0
  100. package/src/rust/components/settings_panel.rs +13 -5
  101. package/src/rust/components/status_bar.rs +110 -178
  102. package/src/rust/components/status_indicator.rs +6 -9
  103. package/src/rust/components/style/mod.rs +5 -0
  104. package/src/rust/components/style/style_provider.rs +2 -0
  105. package/src/rust/components/viewer/filters.rs +133 -0
  106. package/src/rust/components/viewer/msg.rs +182 -0
  107. package/src/rust/components/viewer/panels.rs +326 -0
  108. package/src/rust/components/viewer/render.rs +255 -0
  109. package/src/rust/components/viewer/settings.rs +434 -0
  110. package/src/rust/components/viewer/snapshots.rs +134 -0
  111. package/src/rust/components/viewer/wiring.rs +505 -0
  112. package/src/rust/components/viewer.rs +245 -756
  113. package/src/rust/components/window_editor.rs +1013 -0
  114. package/src/rust/config/export_method.rs +1 -1
  115. package/src/rust/config/mod.rs +4 -0
  116. package/src/rust/config/options.rs +71 -0
  117. package/src/rust/config/viewer_config.rs +24 -2
  118. package/src/rust/config/workspace_config.rs +92 -0
  119. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  120. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  121. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  122. package/src/rust/custom_elements/viewer.rs +1015 -286
  123. package/src/rust/custom_events.rs +212 -61
  124. package/src/rust/js/mod.rs +2 -0
  125. package/src/rust/js/plugin.rs +297 -10
  126. package/src/rust/js/regular_layout.rs +258 -0
  127. package/src/rust/lib.rs +24 -2
  128. package/src/rust/presentation/column_locator.rs +16 -1
  129. package/src/rust/presentation.rs +66 -33
  130. package/src/rust/queries/columns_iter_set.rs +7 -1
  131. package/src/rust/queries/get_viewer_config.rs +29 -11
  132. package/src/rust/queries/plugin_column_styles.rs +2 -2
  133. package/src/rust/renderer/activate.rs +41 -14
  134. package/src/rust/renderer/dispatch.rs +463 -0
  135. package/src/rust/renderer/plugin_config.rs +495 -0
  136. package/src/rust/renderer/registry.rs +18 -6
  137. package/src/rust/renderer/render_timer.rs +29 -10
  138. package/src/rust/renderer.rs +384 -838
  139. package/src/rust/session/column_defaults_update.rs +26 -8
  140. package/src/rust/session/drag_drop_update.rs +18 -0
  141. package/src/rust/session/metadata.rs +66 -9
  142. package/src/rust/session/replace_expression_update.rs +1 -0
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +755 -264
  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/edit_window.rs +155 -0
  152. package/src/rust/tasks/eject.rs +64 -23
  153. package/src/rust/tasks/mod.rs +30 -4
  154. package/src/rust/tasks/pipeline.rs +411 -0
  155. package/src/rust/tasks/presize_panels.rs +324 -0
  156. package/src/rust/tasks/reset_all.rs +22 -12
  157. package/src/rust/tasks/resize_observer.rs +124 -87
  158. package/src/rust/tasks/restore_and_render.rs +80 -86
  159. package/src/rust/tasks/restore_panel.rs +145 -0
  160. package/src/rust/tasks/send_column_config.rs +5 -2
  161. package/src/rust/tasks/send_plugin_config.rs +5 -2
  162. package/src/rust/tasks/set_edit_mode.rs +59 -0
  163. package/src/rust/tasks/sync_update_panels.rs +152 -0
  164. package/src/rust/tasks/table_lifecycle.rs +158 -0
  165. package/src/rust/tasks/update_theme.rs +56 -12
  166. package/src/rust/utils/browser/dragdrop.rs +12 -0
  167. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  168. package/src/rust/utils/completion.rs +56 -0
  169. package/src/rust/utils/custom_element.rs +5 -0
  170. package/src/rust/utils/debounce.rs +189 -17
  171. package/src/rust/utils/mod.rs +6 -0
  172. package/src/rust/utils/pending_effects.rs +62 -0
  173. package/src/rust/utils/spawn.rs +33 -0
  174. package/src/rust/utils/weak_scope.rs +0 -10
  175. package/src/rust/workspace.rs +846 -0
  176. package/src/svg/active-indicator.svg +3 -0
  177. package/src/svg/pin-icon.svg +5 -0
  178. package/src/themes/botanical.css +5 -2
  179. package/src/themes/defaults.css +54 -2
  180. package/src/themes/dracula.css +6 -3
  181. package/src/themes/gruvbox-dark.css +5 -2
  182. package/src/themes/gruvbox.css +5 -2
  183. package/src/themes/icons.css +5 -2
  184. package/src/themes/intl/de.css +7 -0
  185. package/src/themes/intl/es.css +7 -0
  186. package/src/themes/intl/fr.css +7 -0
  187. package/src/themes/intl/ja.css +7 -0
  188. package/src/themes/intl/pt.css +7 -0
  189. package/src/themes/intl/zh.css +7 -0
  190. package/src/themes/intl.css +10 -1
  191. package/src/themes/monokai.css +5 -2
  192. package/src/themes/phosphor.css +5 -2
  193. package/src/themes/pro-dark.css +5 -2
  194. package/src/themes/pro.css +2 -1
  195. package/src/themes/solarized-dark.css +5 -2
  196. package/src/themes/solarized.css +5 -2
  197. package/src/themes/vaporwave.css +5 -2
  198. package/src/ts/bootstrap.ts +8 -7
  199. package/src/ts/extensions.ts +30 -2
  200. package/src/ts/perspective-viewer.ts +10 -0
  201. package/src/ts/plugin.ts +72 -5
  202. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  203. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  204. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  205. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  206. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  207. package/src/ts/ts-rs/Layout.ts +9 -0
  208. package/src/ts/ts-rs/Orientation.ts +6 -0
  209. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  210. package/src/ts/ts-rs/PanelViewerConfig.ts +16 -0
  211. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  212. package/src/ts/ts-rs/TabLayout.ts +11 -0
  213. package/src/ts/ts-rs/ViewerConfig.ts +7 -2
  214. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  215. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  216. package/src/ts/ts-rs/WindowSort.ts +9 -0
  217. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  218. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  219. package/src/ts/ts-rs/Windows.ts +10 -0
  220. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  221. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  222. package/src/rust/tasks/intersection_observer.rs +0 -98
  223. package/src/rust/tasks/update_and_render.rs +0 -110
  224. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-8a8c7d5d696b43ae/inline1.js} +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-8a8c7d5d696b43ae/inline2.js} +0 -0
  226. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-8a8c7d5d696b43ae/inline3.js} +0 -0
  227. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-8a8c7d5d696b43ae/inline5.js} +0 -0
@@ -0,0 +1,224 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! `MainPanel::rendered` reconcile: attach the layout listeners once, apply a
14
+ //! `restore`-staged layout tree, and `insertPanel`/`removePanel` cells
15
+ //! against `panel_ids`. Purely STRUCTURAL — it never touches plugin
16
+ //! rendering or paint-affecting plugin attributes (those belong to locked
17
+ //! draw dispatches; see the note at the end of `reconcile`).
18
+
19
+ use perspective_js::utils::JsValueSerdeExt;
20
+ use wasm_bindgen::prelude::*;
21
+ use yew::prelude::*;
22
+
23
+ use super::MainPanel;
24
+ use crate::js::RegularLayout;
25
+
26
+ /// Layout interaction tuning constants, applied via
27
+ /// [`RegularLayout::restore_physics`] when the layout element mounts.
28
+ #[derive(serde::Serialize)]
29
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
30
+ struct LayoutPhysics {
31
+ grid_divider_size: f64,
32
+ split_edge_tolerance: f64,
33
+ split_root_edge_tolerance: f64,
34
+ }
35
+
36
+ const LAYOUT_PHYSICS: LayoutPhysics = LayoutPhysics {
37
+ grid_divider_size: 6.0,
38
+ split_edge_tolerance: 0.33,
39
+ split_root_edge_tolerance: 0.1,
40
+ };
41
+
42
+ impl MainPanel {
43
+ /// Size each STAGED panel's hidden wrapper (`.psp-staging` — see
44
+ /// `MainPanel::render` and `Workspace::stage_panel`) to its PREDICTED
45
+ /// post-insert cell: an equal share of the layout box's width (the
46
+ /// reconcile insert splits the root horizontally with equal
47
+ /// redistribution) minus the frame-chrome fallback the presize sweep
48
+ /// uses. Prediction only — the staged first draw renders at these
49
+ /// dimensions, and any delta from the real committed cell is
50
+ /// reconciled by the post-commit reactive resize (free when exact,
51
+ /// via the charts transport's dims-unchanged guard). Imperative
52
+ /// because it MEASURES the committed layout box, which a `view()`
53
+ /// style prop cannot.
54
+ pub(super) fn size_staging_wrappers(&self) {
55
+ let Some(root) = self.main_panel_ref.cast::<web_sys::Element>() else {
56
+ return;
57
+ };
58
+
59
+ let Ok(wrappers) = root.query_selector_all(".psp-staging") else {
60
+ return;
61
+ };
62
+
63
+ if wrappers.length() == 0 {
64
+ return;
65
+ }
66
+
67
+ let Some(layout) = self.layout_ref.cast::<web_sys::HtmlElement>() else {
68
+ return;
69
+ };
70
+
71
+ let stage = layout.get_bounding_client_rect();
72
+ let shares = (self.inserted.len() + wrappers.length() as usize).max(1) as f64;
73
+ let (chrome_w, chrome_h) = crate::tasks::CHROME_FALLBACK;
74
+ let width = (stage.width() / shares - chrome_w).max(0.0);
75
+ let height = (stage.height() - chrome_h).max(0.0);
76
+ for i in 0..wrappers.length() {
77
+ if let Some(node) = wrappers.get(i)
78
+ && let Some(el) = node.dyn_ref::<web_sys::HtmlElement>()
79
+ {
80
+ let _ = el.style().set_property("width", &format!("{width}px"));
81
+ let _ = el.style().set_property("height", &format!("{height}px"));
82
+ }
83
+ }
84
+ }
85
+
86
+ /// Reconcile the `<regular-layout>` grid against `panel_ids`: `insertPanel`
87
+ /// newly-rendered cells (flipping each from `display:none` to a visible
88
+ /// grid cell) and `removePanel` cells whose panels are gone.
89
+ pub(super) fn reconcile(&mut self, ctx: &Context<Self>) {
90
+ // The `<regular-layout>` element is rendered unconditionally and so is
91
+ // stable for this component's lifetime — zero panels is just another
92
+ // count (empty insert loop; the retain below removes the last cell,
93
+ // collapsing the layout tree to a childless split). Do NOT reset
94
+ // `listener_target`/`inserted` on empty: the listeners stay attached
95
+ // to the persistent element.
96
+ let Some(el) = self.layout_ref.cast::<web_sys::HtmlElement>() else {
97
+ return;
98
+ };
99
+
100
+ // Attach the close-detection + active-panel-sync listeners once per
101
+ // ELEMENT (the `<regular-layout>` element is keyed into a fully-keyed
102
+ // sibling list, so Yew reuses one instance for this MainPanel's
103
+ // lifetime), and configure resize physics — `GRID_DIVIDER_SIZE` is the
104
+ // divider hit tolerance (0 by default → not resizable). The cell-edge
105
+ // band it grabs must be left uncovered: each `.rl-panel` carries a
106
+ // `margin` (viewer.css) so pointerdowns there reach `regular-layout`
107
+ // instead of the frame. Comparing the ELEMENT (not a latched bool)
108
+ // self-heals an instance swap: a fresh element boots an EMPTY layout
109
+ // tree with no listeners, so everything must be re-bound and every
110
+ // placed panel re-inserted (see `listener_target`).
111
+ if self.listener_target.as_ref() != Some(&el) {
112
+ if self.listener_target.is_some() {
113
+ self.inserted.clear();
114
+ }
115
+
116
+ let _ = el.add_event_listener_with_callback(
117
+ RegularLayout::UPDATE_EVENT,
118
+ self._layout_update_listener.as_ref().unchecked_ref(),
119
+ );
120
+
121
+ let _ = el.add_event_listener_with_callback(
122
+ RegularLayout::SELECT_EVENT,
123
+ self._layout_select_listener.as_ref().unchecked_ref(),
124
+ );
125
+
126
+ let _ = el.add_event_listener_with_callback(
127
+ RegularLayout::BEFORE_RESIZE_EVENT,
128
+ self._layout_before_resize_listener.as_ref().unchecked_ref(),
129
+ );
130
+
131
+ if let Ok(physics) = JsValue::from_serde_ext(&LAYOUT_PHYSICS) {
132
+ el.unchecked_ref::<RegularLayout>()
133
+ .restore_physics(&physics);
134
+ }
135
+
136
+ self.listener_target = Some(el.clone());
137
+ }
138
+
139
+ let layout: RegularLayout = el.unchecked_into();
140
+ let panel_ids = &ctx.props().panel_ids;
141
+
142
+ // Whole-element restore stages its saved layout tree on the Workspace
143
+ // (the model; regular-layout is a slave view). Apply it here, BEFORE
144
+ // the insert reconcile, and seed `inserted` from its panel names — so
145
+ // restored panels mount directly at their saved positions in ONE
146
+ // layout commit, never transiting the synthetic equal-split inserts
147
+ // below.
148
+ if let Some(tree) = ctx.props().workspace.take_pending_layout()
149
+ && let Ok(js) = JsValue::from_serde_ext(&tree)
150
+ {
151
+ layout.restore_sync(&js);
152
+ for name in tree.slot_names() {
153
+ if !self.inserted.contains(&name) {
154
+ self.inserted.push(name);
155
+ }
156
+ }
157
+ }
158
+
159
+ // Insert cells that are newly present.
160
+ for id in panel_ids {
161
+ let name = id.as_str();
162
+ if self.inserted.iter().any(|n| n == name) {
163
+ continue;
164
+ }
165
+
166
+ // STAGED panels are withheld from the layout: their first draw
167
+ // is completing in the hidden staging wrapper (see
168
+ // `Workspace::stage_panel`). The promote re-render clears the
169
+ // flag, and this loop then inserts the already-drawn panel.
170
+ if ctx.props().workspace.is_staged(id) {
171
+ continue;
172
+ }
173
+
174
+ // Already placed in the layout tree (the layout is the placement
175
+ // source of truth — e.g. a restored tree naming a panel this
176
+ // component hasn't tracked yet): record it, never re-split it.
177
+ if layout.contains_panel(name) {
178
+ self.inserted.push(name.to_owned());
179
+ continue;
180
+ }
181
+
182
+ // Insert at this panel's index as a split (orientation = `true` →
183
+ // horizontal) rather than the default (path `[]`), which would
184
+ // *stack* into the root tab-layout — a stack only renders its
185
+ // selected tab, and without frame chrome there's no tab-bar to
186
+ // reach the others. Splitting keeps every panel visible side-by-side.
187
+ let index = self.inserted.len();
188
+ let path = JsValue::from(js_sys::Array::of1(&JsValue::from_f64(index as f64)));
189
+ let _ = layout.insert_panel(name, path, JsValue::from_bool(true));
190
+ self.inserted.push(name.to_owned());
191
+ }
192
+
193
+ // Remove cells whose panels are gone.
194
+ self.inserted.retain(|name| {
195
+ if panel_ids.iter().any(|id| id.as_str() == name) {
196
+ true
197
+ } else {
198
+ let _ = layout.remove_panel(name);
199
+ false
200
+ }
201
+ });
202
+
203
+ // A removed panel can't stay "maximized" (regular-layout drops the
204
+ // maximize stylesheet when its panel leaves the layout).
205
+ if let Some(m) = &self.maximized
206
+ && !panel_ids.iter().any(|id| id.as_str() == m.as_str())
207
+ {
208
+ self.maximized = None;
209
+ }
210
+
211
+ // NOTE: neither the plugin `theme` attribute nor the `active` class
212
+ // is managed here. This pass is an async render — mutating
213
+ // paint-affecting plugin state from it splits the change and the
214
+ // plugin DOM it styles across two paints (the datagrid's "wrong-row
215
+ // EDIT" artifact), and inferring "needs restyle" from DOM state here
216
+ // raced in-flight locked runs (it once captured a pre-rebuild `View`
217
+ // and restyled it after its deletion). Both stamps are applied by
218
+ // `Renderer::stamp_active`/`stamp_theme` INSIDE locked plugin
219
+ // dispatches, atomic with the draw ("stamp before draw"); theme
220
+ // CHANGES are restyled by their mutation sites (the theme-picker
221
+ // task, `restorePanel`, `resetThemes`, and the root's default-theme
222
+ // fan-out in `snapshots.rs::on_update_presentation`).
223
+ }
224
+ }
@@ -0,0 +1,385 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! `MainPanel::view`: the shared status bar, the `<regular-layout>` cells (one
14
+ //! forwarding `<slot>` wrapper per panel), the per-panel `<PanelTab>`s, and the
15
+ //! cursor-anchored `<PanelMenu>`.
16
+
17
+ use wasm_bindgen::prelude::*;
18
+ use yew::prelude::*;
19
+
20
+ use super::{MainPanel, MainPanelMsg, MainPanelProps};
21
+ use crate::components::panel_menu::PanelMenu;
22
+ use crate::components::panel_tab::PanelTab;
23
+ use crate::components::render_warning::RenderWarning;
24
+ use crate::components::status_bar::StatusBar;
25
+ use crate::js::RegularLayout;
26
+ use crate::tasks::dismiss_render_warning_callback;
27
+ use crate::workspace::PanelId;
28
+
29
+ /// Format a string as a CSS string literal (double-quoted, with `\` and `"`
30
+ /// escaped) — for use as a `--regular-layout-<id>--title` custom-property
31
+ /// value.
32
+ fn css_string(s: &str) -> String {
33
+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
34
+ format!("\"{escaped}\"")
35
+ }
36
+
37
+ impl MainPanel {
38
+ pub(super) fn render(&self, ctx: &Context<Self>) -> Html {
39
+ let MainPanelProps {
40
+ presentation,
41
+ renderer,
42
+ session,
43
+ ..
44
+ } = ctx.props();
45
+
46
+ let is_settings_open = ctx.props().is_settings_open;
47
+ let mut class = classes!();
48
+ if !is_settings_open {
49
+ class.push("settings-closed");
50
+ }
51
+
52
+ if ctx.props().is_title() {
53
+ class.push("titled");
54
+ }
55
+
56
+ let pointerdown = ctx.link().callback(MainPanelMsg::PointerEvent);
57
+ let on_dismiss_warning = dismiss_render_warning_callback(session, renderer);
58
+
59
+ // Each panel's plugin is mounted in the viewer's light DOM under its
60
+ // panel-id slot (see `renderer::activate`). Render one `<regular-layout>`
61
+ // cell per panel: a concrete `<div name=…>` wrapper (which
62
+ // `regular-layout` grid-places via its `::slotted([name])` rule — that
63
+ // only matches *direct*, non-`<slot>` children, hence the wrapper) whose
64
+ // inner forwarding `<slot>` projects the light-DOM plugin into the placed
65
+ // cell. The `<regular-layout>` element is ALWAYS rendered — at zero
66
+ // panels it is merely empty (its tree collapses to a childless split,
67
+ // and it paints nothing of its own), which keeps the stage CSS
68
+ // (viewer.css keys the stage background + inset bleed on the element)
69
+ // and the attached listeners alive across empty↔populated transitions.
70
+ let plugin_area = {
71
+ // The active panel (whose engines the status bar/settings target) is
72
+ // the one the active renderer is bound to.
73
+ let active_slot = renderer.slot_name();
74
+ // Panel-count chrome (`single`/`multi`, closable, draggable)
75
+ let placed_count = ctx.props().workspace.placed_count();
76
+ let cells = ctx
77
+ .props()
78
+ .panel_ids
79
+ .iter()
80
+ .filter(|id| !ctx.props().workspace.is_staged(id))
81
+ .map(|id| {
82
+ let name = id.as_str().to_owned();
83
+ let mut class = classes!("rl-panel");
84
+ if placed_count > 1 {
85
+ if active_slot.as_deref() == Some(name.as_str()) {
86
+ class.push("active");
87
+ }
88
+ } else {
89
+ // Lone panel: can't be closed to zero — hide the close button
90
+ // (see viewer.css) and skip the active outline.
91
+ class.push("single");
92
+ }
93
+
94
+ // Activate this panel on pointerdown anywhere in it (body or
95
+ // titlebar), so *working* in a panel — not only clicking its tab
96
+ // — targets it with the shared settings/toolbar. regular-layout
97
+ // emits `regular-layout-select` on tab clicks only, which left
98
+ // body interaction unable to change the active panel. Tab clicks
99
+ // still also fire `-select`, but that dispatch is async (after
100
+ // `restore`) so it lands after this synchronous handler — within a
101
+ // stack, switching tabs still resolves to the clicked panel.
102
+ // Re-activating the already-active panel is a no-op upstream.
103
+ let on_activate = ctx.props().on_activate_panel.clone();
104
+ let activate_name = name.clone();
105
+ let onpointerdown = Callback::from(move |_: web_sys::PointerEvent| {
106
+ on_activate.emit(activate_name.clone());
107
+ });
108
+
109
+ // Right-click anywhere in the panel opens the context menu. This
110
+ // is handled by the imperative `contextmenu` listener on the
111
+ // panel container (see `_contextmenu_listener`), NOT a Yew
112
+ // `oncontextmenu` here: the plugin body is light-DOM attached by
113
+ // the renderer, so Yew's delegated handler — which walks the vdom
114
+ // — never matches a right-click on it, leaving the native menu.
115
+
116
+ // `<regular-layout-frame>` gives each panel a titlebar (its
117
+ // `slot="tab"`) and a `::part(container)`. Two forwarding `<slot>`s
118
+ // project our light-DOM nodes in: `name` → the plugin (into the
119
+ // container), and `tab-<name>` (with `slot="tab"`) → this panel's
120
+ // `<PanelTab>` (into the titlebar). Both targets live in the
121
+ // viewer's light DOM, so the slots — being in the viewer's shadow —
122
+ // collect them and forward them one level deeper into the frame.
123
+ //
124
+ // The frame no longer carries the theme: the per-panel theme is
125
+ // applied directly to the light-DOM plugin + tab (each stamped with
126
+ // a `theme` attribute) via the `perspective-viewer [theme="X"]`
127
+ // document rules, so it themes through the native cascade instead
128
+ // of a scraped/inlined `--psp-*` block.
129
+ html! {
130
+ <regular-layout-frame
131
+ name={name.clone()}
132
+ key={name.clone()}
133
+ {class}
134
+ {onpointerdown}
135
+ >
136
+ <slot name={name.clone()} />
137
+ <slot name={format!("tab-{name}")} slot="tab" />
138
+ </regular-layout-frame>
139
+ }
140
+ });
141
+
142
+ // Per-panel tab titles: regular-layout's tabs render their label from
143
+ // a `--regular-layout-<id>--title` custom property (a CSS string). We
144
+ // set one per titled panel on the `<regular-layout>` host; they
145
+ // inherit into each frame/tab's shadow.
146
+ let title_style = ctx
147
+ .props()
148
+ .panel_titles
149
+ .iter()
150
+ .filter_map(|(id, title)| {
151
+ title
152
+ .as_ref()
153
+ .map(|t| format!("--regular-layout-{id}--title:{};", css_string(t)))
154
+ })
155
+ .collect::<String>();
156
+
157
+ // One `<PanelTab>` per panel, mounted in the viewer's light DOM and
158
+ // forwarded into its frame's titlebar by the `tab-<id>` slot above.
159
+ // Rendered as siblings of `<regular-layout>` (their DOM placement is
160
+ // irrelevant — each portals itself onto the viewer host). `on_select`
161
+ // drives regular-layout's own `select` (the `regular-layout-select`
162
+ // listener picks up activation).
163
+ let viewer_elem = ctx.props().presentation.viewer_elem().clone();
164
+ // A lone panel can't be closed to zero, and has nowhere to be
165
+ // dragged — so it's neither closable nor draggable; its tab also
166
+ // carries the `single` panel-count class (which e.g. hides the
167
+ // caret).
168
+ let closable = placed_count > 1;
169
+ let draggable = closable;
170
+ let single = !closable;
171
+ let on_select = {
172
+ let layout_ref = self.layout_ref.clone();
173
+ Callback::from(move |id: String| {
174
+ if let Some(el) = layout_ref.cast::<web_sys::HtmlElement>() {
175
+ let _ = el.unchecked_ref::<RegularLayout>().select(&id);
176
+ }
177
+ })
178
+ };
179
+ // Close routes through the root `ClosePanel` (the same channel the
180
+ // context-menu "Close" uses), NOT `RegularLayout::remove_panel`.
181
+ // Closing is an APP-initiated state change, so it must mutate the
182
+ // `Workspace` (the source of truth) FIRST and dispose the panel
183
+ // synchronously; `MainPanel`'s reconcile then removes the cell from
184
+ // `regular-layout` on the re-render. Driving the layout first — and
185
+ // discovering the close via the `regular-layout-update` DOM event —
186
+ // ran the layout-update resize fan-out over a still-present-but-
187
+ // doomed panel, spawning an (uncancellable) draw that then raced its
188
+ // disposal ("restore() called before load()").
189
+ let on_close = ctx.props().on_close_panel.clone();
190
+ // Commit an in-tab title edit to that panel's own session — the same
191
+ // `title_changed` → `TitlesChanged` refresh loop that used to be
192
+ // driven by the status bar re-renders the tab with the new title.
193
+ let on_rename = {
194
+ let workspace = ctx.props().workspace.clone();
195
+ Callback::from(move |(id, title): (String, Option<String>)| {
196
+ if let Some(panel) = workspace.panel(&PanelId::from(id.as_str())) {
197
+ panel.session.set_title(title);
198
+ }
199
+ })
200
+ };
201
+ // The tab's open-settings button (the ONLY open-settings
202
+ // affordance — the status bar's button is gone): select the panel
203
+ // in the layout, activate it, then toggle the sidebar open. All
204
+ // three are synchronous root-message sends, so activation lands
205
+ // before the toggle and the sidebar binds the clicked panel's
206
+ // engines.
207
+ let on_open_settings = {
208
+ let on_select = on_select.clone();
209
+ let on_activate = ctx.props().on_activate_panel.clone();
210
+ let on_settings = ctx.props().on_settings.clone();
211
+ Callback::from(move |id: String| {
212
+ on_select.emit(id.clone());
213
+ on_activate.emit(id);
214
+ on_settings.emit(());
215
+ })
216
+ };
217
+ let tabs = ctx
218
+ .props()
219
+ .panel_ids
220
+ .iter()
221
+ .filter(|id| !ctx.props().workspace.is_staged(id))
222
+ .map(|id| {
223
+ let name = id.as_str().to_owned();
224
+ let title = ctx
225
+ .props()
226
+ .panel_titles
227
+ .iter()
228
+ .find(|(pid, _)| pid == &name)
229
+ .and_then(|(_, t)| t.clone());
230
+ let active = active_slot.as_deref() == Some(name.as_str());
231
+ let visible = !self.hidden_tabs.contains(name.as_str());
232
+ let is_master = ctx.props().panel_masters.contains(id);
233
+ let theme = ctx.props().effective_panel_theme(&name);
234
+
235
+ html! {
236
+ <PanelTab
237
+ key={name.clone()}
238
+ viewer={viewer_elem.clone()}
239
+ panel_id={name.clone()}
240
+ {title}
241
+ {theme}
242
+ {active}
243
+ {visible}
244
+ {is_master}
245
+ {single}
246
+ {closable}
247
+ {is_settings_open}
248
+ {draggable}
249
+ on_select={on_select.clone()}
250
+ on_close={on_close.clone()}
251
+ on_open_settings={on_open_settings.clone()}
252
+ on_context_menu={ctx.link().callback(|(id, x, y): (String, f64, f64)| MainPanelMsg::ContextMenu(Some(id), x, y))}
253
+ on_rename={on_rename.clone()}
254
+ />
255
+ }
256
+ })
257
+ .collect::<Vec<_>>();
258
+
259
+ // STAGED panels: the plugin `<slot>` is delivered OUTSIDE the
260
+ // layout into a hidden wrapper, sized to the predicted
261
+ // post-insert cell by `size_staging_wrappers`. `visibility:
262
+ // hidden` (not `display: none`) so the plugin keeps a real box
263
+ // and a non-null `offsetParent` — first draws, redraw
264
+ // subscriptions and every observer (the host-level
265
+ // `AutoPauseHandle`/`ResizeObserverHandle` gates, the drag
266
+ // `ResizeObserver`s) see it exactly as they would a visible
267
+ // panel. No tab is rendered while staged.
268
+ let staging = ctx
269
+ .props()
270
+ .panel_ids
271
+ .iter()
272
+ .filter(|id| ctx.props().workspace.is_staged(id))
273
+ .map(|id| {
274
+ let name = id.as_str().to_owned();
275
+ html! {
276
+ <div
277
+ class="psp-staging"
278
+ data-panel={name.clone()}
279
+ key={format!("staging-{name}")}
280
+ >
281
+ <slot {name} />
282
+ </div>
283
+ }
284
+ });
285
+
286
+ // ONE flat FULLY-KEYED list: `<regular-layout>` + tabs + staging
287
+ // wrappers. Keying every sibling (the layout element included)
288
+ // routes Yew's list diff through `apply_keyed`, which matches by
289
+ // key — the layout element is REUSED across renders regardless of
290
+ // how the tab/staging population shifts around it. The prior
291
+ // shape (`<regular-layout>` + nested `{tabs}`/`{staging}` lists,
292
+ // layout unkeyed) took `apply_unkeyed`, whose back-to-front
293
+ // positional pairing REPLACED the layout element whenever the
294
+ // trailing lists changed length — orphaning its committed tree
295
+ // and attached listeners, and silently re-booting the layout
296
+ // from `EMPTY_PANEL` (see `listener_target`).
297
+ std::iter::once(html! {
298
+ <regular-layout
299
+ key="__regular_layout__"
300
+ ref={self.layout_ref.clone()}
301
+ style={title_style}
302
+ >
303
+ { for cells }
304
+ </regular-layout>
305
+ })
306
+ .chain(tabs)
307
+ .chain(staging)
308
+ .collect::<Html>()
309
+ };
310
+
311
+ // The cursor-anchored panel command menu — a body-mounted
312
+ // `PortalModal` (like the Export/Copy pickers it spawns), so its
313
+ // placement in this subtree is irrelevant. Themed by the TARGET
314
+ // panel's effective theme — its own, else the registry default —
315
+ // not the active/host theme (the target-less stage menu also takes
316
+ // the registry default). Maximize/Restore are handled locally;
317
+ // other commands forward to the root.
318
+ let panel_menu = self
319
+ .context_menu
320
+ .as_ref()
321
+ .map(|(x, y, id)| {
322
+ let theme = match id {
323
+ Some(id) => ctx.props().effective_panel_theme(id),
324
+ None => ctx
325
+ .props()
326
+ .presentation_props
327
+ .available_themes
328
+ .first()
329
+ .cloned(),
330
+ };
331
+
332
+ let maximized = id
333
+ .as_deref()
334
+ .is_some_and(|id| self.maximized.as_deref() == Some(id));
335
+
336
+ html! {
337
+ <PanelMenu
338
+ x={*x}
339
+ y={*y}
340
+ panel_id={id.clone()}
341
+ workspace={ctx.props().workspace.clone()}
342
+ presentation={ctx.props().presentation.clone()}
343
+ {theme}
344
+ {maximized}
345
+ on_command={ctx.link().callback(MainPanelMsg::Command)}
346
+ on_close={ctx.link().callback(|_| MainPanelMsg::CloseContextMenu)}
347
+ />
348
+ }
349
+ })
350
+ .unwrap_or_default();
351
+
352
+ html! {
353
+ <div id="main_column">
354
+ <StatusBar
355
+ id="status_bar"
356
+ on_reset={ctx.props().on_reset.clone()}
357
+ session_props={ctx.props().session_props.clone()}
358
+ presentation_props={ctx.props().presentation_props.clone()}
359
+ is_settings_open={ctx.props().is_settings_open}
360
+ update_count={ctx.props().update_count}
361
+ global_filters={ctx.props().global_filters.clone()}
362
+ on_remove_global_filter={ctx.props().on_remove_global_filter.clone()}
363
+ on_clear_global_filters={ctx.props().on_clear_global_filters.clone()}
364
+ {presentation}
365
+ {renderer}
366
+ {session}
367
+ workspace={ctx.props().workspace.clone()}
368
+ />
369
+ <div
370
+ id="main_panel_container"
371
+ ref={self.main_panel_ref.clone()}
372
+ {class}
373
+ onpointerdown={pointerdown}
374
+ >
375
+ <RenderWarning
376
+ on_dismiss={on_dismiss_warning}
377
+ dimensions={ctx.props().renderer_props.render_limits}
378
+ />
379
+ { plugin_area }
380
+ </div>
381
+ { panel_menu }
382
+ </div>
383
+ }
384
+ }
385
+ }