@perspective-dev/viewer 5.5.1 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  43. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  44. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  45. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  46. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  47. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  48. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  49. package/dist/wasm/perspective-viewer.d.ts +115 -35
  50. package/dist/wasm/perspective-viewer.js +314 -190
  51. package/dist/wasm/perspective-viewer.wasm +0 -0
  52. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  53. package/package.json +31 -18
  54. package/src/css/column-selector.css +205 -50
  55. package/src/css/ui/containers/context-menu.css +12 -1
  56. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  57. package/src/css/ui/containers/scroll-panel.css +4 -1
  58. package/src/rust/agent/client/protocol.rs +0 -89
  59. package/src/rust/agent/markdown/mod.rs +0 -238
  60. package/src/rust/agent/preamble.md +1 -0
  61. package/src/rust/agent/tools.rs +2 -16
  62. package/src/rust/components/column_selector/active_column.rs +15 -5
  63. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  64. package/src/rust/components/column_selector/config_selector.rs +34 -6
  65. package/src/rust/components/column_selector/derived_control.rs +229 -0
  66. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  67. package/src/rust/components/column_selector/filter_column.rs +0 -2
  68. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  69. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  70. package/src/rust/components/column_selector/sort_column.rs +4 -3
  71. package/src/rust/components/column_selector.rs +21 -1
  72. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  73. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  74. package/src/rust/components/column_settings_sidebar.rs +25 -19
  75. package/src/rust/components/debug_panel.rs +38 -29
  76. package/src/rust/components/dragdrop_list.rs +8 -9
  77. package/src/rust/components/main_panel/msg.rs +6 -4
  78. package/src/rust/components/main_panel/reconcile.rs +6 -1
  79. package/src/rust/components/main_panel/render.rs +70 -51
  80. package/src/rust/components/main_panel/update.rs +39 -1
  81. package/src/rust/components/main_panel.rs +97 -59
  82. package/src/rust/components/new_panel_menu.rs +69 -1
  83. package/src/rust/components/panel_menu.rs +63 -12
  84. package/src/rust/components/plugin_tab.rs +3 -2
  85. package/src/rust/components/render_warning.rs +2 -1
  86. package/src/rust/components/settings_panel.rs +4 -4
  87. package/src/rust/components/status_bar.rs +74 -24
  88. package/src/rust/components/status_indicator.rs +8 -1
  89. package/src/rust/components/viewer/filters.rs +13 -1
  90. package/src/rust/components/viewer/msg.rs +4 -0
  91. package/src/rust/components/viewer/panels.rs +41 -1
  92. package/src/rust/components/viewer/render.rs +1 -0
  93. package/src/rust/components/viewer/settings.rs +0 -22
  94. package/src/rust/components/viewer/snapshots.rs +2 -2
  95. package/src/rust/components/viewer/wiring.rs +14 -0
  96. package/src/rust/components/viewer.rs +45 -25
  97. package/src/rust/components/window_editor.rs +5 -4
  98. package/src/rust/config/column_config_schema.rs +38 -377
  99. package/src/rust/config/number_string_format.rs +0 -94
  100. package/src/rust/config/options.rs +3 -7
  101. package/src/rust/config/plugin_static_config.rs +0 -42
  102. package/src/rust/config/viewer_config.rs +47 -17
  103. package/src/rust/config/workspace_config.rs +158 -128
  104. package/src/rust/custom_elements/viewer.rs +187 -131
  105. package/src/rust/custom_events.rs +33 -18
  106. package/src/rust/derived.rs +266 -0
  107. package/src/rust/exprtk/tokenize.rs +0 -54
  108. package/src/rust/js/plugin.rs +1 -1
  109. package/src/rust/lib.rs +3 -0
  110. package/src/rust/presentation.rs +23 -4
  111. package/src/rust/queries/column_locator.rs +55 -48
  112. package/src/rust/queries/get_viewer_config.rs +1 -1
  113. package/src/rust/queries/plugin_column_styles.rs +22 -23
  114. package/src/rust/renderer/dispatch.rs +2 -0
  115. package/src/rust/renderer/plugin_config.rs +652 -487
  116. package/src/rust/renderer/props.rs +4 -0
  117. package/src/rust/renderer/registry.rs +2 -6
  118. package/src/rust/renderer/state.rs +205 -0
  119. package/src/rust/renderer.rs +201 -59
  120. package/src/rust/session/metadata.rs +30 -9
  121. package/src/rust/session/op_queue.rs +430 -0
  122. package/src/rust/session/panel_state.rs +327 -0
  123. package/src/rust/session/view_subscription.rs +42 -3
  124. package/src/rust/session.rs +900 -395
  125. package/src/rust/tasks/apply_global_filters.rs +49 -35
  126. package/src/rust/tasks/auto_pause.rs +22 -12
  127. package/src/rust/tasks/copy_export.rs +5 -0
  128. package/src/rust/tasks/create_panel.rs +34 -9
  129. package/src/rust/tasks/derive.rs +312 -0
  130. package/src/rust/tasks/mod.rs +6 -2
  131. package/src/rust/tasks/pipeline.rs +145 -89
  132. package/src/rust/tasks/reset_all.rs +31 -20
  133. package/src/rust/tasks/restore_derived.rs +200 -0
  134. package/src/rust/tasks/restore_panel.rs +153 -141
  135. package/src/rust/tasks/send_column_config.rs +56 -29
  136. package/src/rust/tasks/send_plugin_config.rs +52 -23
  137. package/src/rust/tasks/set_edit_mode.rs +33 -25
  138. package/src/rust/tasks/sync_update_panels.rs +39 -12
  139. package/src/rust/tasks/table_lifecycle.rs +70 -34
  140. package/src/rust/tasks/transactional_restore.rs +387 -0
  141. package/src/rust/tasks/update_theme.rs +45 -6
  142. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  143. package/src/rust/ui/containers/split_panel.rs +0 -41
  144. package/src/rust/ui/context_menu.rs +157 -18
  145. package/src/rust/ui/font_loader.rs +23 -35
  146. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  147. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  148. package/src/rust/ui/form/palette_selector.rs +0 -51
  149. package/src/rust/ui/portal.rs +4 -10
  150. package/src/rust/utils/browser/mod.rs +2 -3
  151. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  152. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  153. package/src/rust/utils/css.rs +0 -360
  154. package/src/rust/utils/local_fonts.rs +0 -36
  155. package/src/rust/utils/mod.rs +2 -3
  156. package/src/rust/utils/modal_position.rs +15 -21
  157. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  158. package/src/rust/utils/pubsub.rs +0 -1
  159. package/src/rust/workspace.rs +187 -140
  160. package/src/svg/derived.svg +6 -0
  161. package/src/themes/icons.css +1 -0
  162. package/src/themes/intl/ar.css +10 -0
  163. package/src/themes/intl/de.css +10 -0
  164. package/src/themes/intl/es.css +10 -0
  165. package/src/themes/intl/fr.css +10 -0
  166. package/src/themes/intl/ja.css +10 -0
  167. package/src/themes/intl/pt.css +10 -0
  168. package/src/themes/intl/zh.css +10 -0
  169. package/src/themes/intl.css +10 -0
  170. package/src/ts/extensions.ts +3 -36
  171. package/src/ts/perspective-viewer.ts +1 -1
  172. package/src/ts/plugin.ts +4 -1
  173. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  174. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  175. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  176. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  177. package/src/ts/ts-rs/TableRef.ts +9 -0
  178. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  179. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  180. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  181. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  182. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  183. package/dist/esm/perspective-viewer.inline.js +0 -21
  184. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  185. package/src/rust/tasks/restore_and_render.rs +0 -107
  186. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  187. package/src/rust/utils/tests/mod.rs +0 -14
  188. package/src/rust/utils/tests/pubsub.rs +0 -95
  189. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  190. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  191. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  192. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  193. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  194. /package/dist/wasm/snippets/{perspective-viewer-6813b568219c6763 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -13,26 +13,62 @@
13
13
  //! Theme reset / set task.
14
14
 
15
15
  use futures::future::join_all;
16
+ use perspective_client::clone;
16
17
  use perspective_js::utils::*;
17
18
 
18
19
  use crate::presentation::Presentation;
19
- use crate::renderer::Renderer;
20
+ use crate::renderer::{Renderer, StagedEdit};
21
+ use crate::session::{EditDelta, OpKind, Session};
20
22
  use crate::workspace::Workspace;
21
23
 
22
24
  /// Give `renderer` the concrete registry default IF it has none yet — the
23
25
  /// cold-boot case, where the panel was created before the registry first
24
26
  /// parsed and `active_theme_name_sync` had nothing to resolve. Only ever
25
27
  /// FILLS IN, never overwrites, so it cannot repaint a themed panel.
26
- pub(crate) async fn seed_panel_theme(presentation: &Presentation, renderer: &Renderer) {
28
+ pub(crate) async fn seed_panel_theme(
29
+ presentation: &Presentation,
30
+ session: &Session,
31
+ renderer: &Renderer,
32
+ ) {
27
33
  if renderer.theme().is_none() {
28
- renderer.set_theme(presentation.get_default_theme_name().await);
34
+ submit_theme(
35
+ session,
36
+ renderer,
37
+ presentation.get_default_theme_name().await,
38
+ );
29
39
  }
30
40
  }
31
41
 
42
+ /// Submit a theme pick as a UI edit, visible to the UI from this call.
43
+ pub fn submit_theme(session: &Session, renderer: &Renderer, theme: Option<String>) {
44
+ let staged = renderer.stage(StagedEdit::Theme(theme.clone()));
45
+ let kind = OpKind::Edit {
46
+ delta: EditDelta::Renderer,
47
+ swaps_plugin: false,
48
+ };
49
+
50
+ let _ticket = session.submit(kind, {
51
+ clone!(renderer);
52
+ move |_ctx| {
53
+ Box::pin(async move {
54
+ renderer.commit_theme(theme);
55
+ drop(staged);
56
+ Ok(None)
57
+ })
58
+ }
59
+ });
60
+ }
61
+
62
+ /// [`submit_theme`] plus a synchronous [`Renderer::stamp_theme`].
63
+ pub fn submit_theme_stamped(session: &Session, renderer: &Renderer, theme: Option<String>) {
64
+ submit_theme(session, renderer, theme);
65
+ renderer.stamp_theme(None);
66
+ }
67
+
32
68
  /// [`seed_panel_theme`] for every panel.
33
69
  pub(crate) async fn seed_default_themes(presentation: &Presentation, workspace: &Workspace) {
34
70
  for panel in workspace.panels() {
35
- seed_panel_theme(presentation, &panel.renderer).await;
71
+ seed_panel_theme(presentation, &panel.session, &panel.renderer).await;
36
72
  }
37
73
  }
38
74
 
@@ -55,6 +91,7 @@ pub(crate) async fn seed_default_themes(presentation: &Presentation, workspace:
55
91
  /// stamped at the plugin's last capture): the picked panel when the value
56
92
  /// is genuinely new, and never a panel that has yet to first-paint.
57
93
  pub fn update_theme(
94
+ session: &Session,
58
95
  renderer: &Renderer,
59
96
  presentation: &Presentation,
60
97
  workspace: &Workspace,
@@ -65,12 +102,13 @@ pub fn update_theme(
65
102
  // cascade styles. Only "reset to default" has to await the registry, and
66
103
  // it records the resolved NAME, never an empty theme.
67
104
  if let Some(name) = &theme {
68
- renderer.set_theme_stamped(Some(name.clone()));
105
+ submit_theme_stamped(session, renderer, Some(name.clone()));
69
106
  }
70
107
 
71
108
  let presentation = presentation.clone();
72
109
  let workspace = workspace.clone();
73
110
  let renderer = renderer.clone();
111
+ let session = session.clone();
74
112
  ApiFuture::spawn(async move {
75
113
  match theme {
76
114
  Some(name) => {
@@ -78,7 +116,8 @@ pub fn update_theme(
78
116
  },
79
117
  None => {
80
118
  presentation.reset_theme().await?;
81
- renderer.set_theme_stamped(presentation.get_default_theme_name().await);
119
+ let default = presentation.get_default_theme_name().await;
120
+ submit_theme_stamped(&session, &renderer, default);
82
121
  },
83
122
  }
84
123
 
@@ -195,6 +195,22 @@ impl Component for ScrollPanel {
195
195
  };
196
196
 
197
197
  let width_style = format!("width:{}px", self.viewport_width.max(0.0));
198
+ let mut rows = windowed_items
199
+ .iter()
200
+ .cloned()
201
+ .map(Html::from)
202
+ .collect::<Vec<_>>();
203
+
204
+ if !ctx.props().omit_autosize_div {
205
+ rows.push(html! {
206
+ <div
207
+ key="__scroll-panel-auto-width__"
208
+ class="scroll-panel-auto-width"
209
+ style={width_style}
210
+ />
211
+ });
212
+ }
213
+
198
214
  let items = if !windowed_items.is_empty() {
199
215
  let onscroll = ctx.link().batch_callback(|_| {
200
216
  vec![
@@ -214,16 +230,7 @@ impl Component for ScrollPanel {
214
230
  ondrop={&ctx.props().drop}
215
231
  class={ctx.props().class.clone()}
216
232
  >
217
- <div class="scroll-panel-container" style={window_style}>
218
- { for windowed_items.iter().cloned().map(Html::from) }
219
- if !ctx.props().omit_autosize_div {
220
- <div
221
- key="__scroll-panel-auto-width__"
222
- class="scroll-panel-auto-width"
223
- style={width_style}
224
- />
225
- }
226
- </div>
233
+ <div class="scroll-panel-container" style={window_style}>{ for rows }</div>
227
234
  <div class="scroll-panel-content" style={content_style} />
228
235
  </div>
229
236
  }
@@ -496,44 +496,3 @@ impl ResizingState {
496
496
  Ok(self.body_style.set_property("cursor", &self.cursor)?)
497
497
  }
498
498
  }
499
-
500
- #[cfg(test)]
501
- mod tests {
502
- use super::*;
503
-
504
- #[test]
505
- fn html_if_empty_branch_is_not_literally_empty() {
506
- let via_if: Html = html! { if false { <div /> } else { <></> } };
507
- assert_ne!(via_if, html! { <></> });
508
- }
509
-
510
- #[test]
511
- fn empty_fragment_is_empty() {
512
- assert!(is_empty_html(&html! { <></> }));
513
- }
514
-
515
- #[test]
516
- fn html_if_empty_branch_is_semantically_empty() {
517
- assert!(is_empty_html(&html! { if false { <div/> } else { <></> } }));
518
- }
519
-
520
- #[test]
521
- fn nested_empty_fragments_are_empty() {
522
- assert!(is_empty_html(&html! { <><><></></></> }));
523
- }
524
-
525
- #[test]
526
- fn tag_is_not_empty() {
527
- assert!(!is_empty_html(&html! { <div/> }));
528
- }
529
-
530
- #[test]
531
- fn fragment_containing_a_tag_is_not_empty() {
532
- assert!(!is_empty_html(&html! { <><div/></> }));
533
- }
534
-
535
- #[test]
536
- fn text_is_not_empty() {
537
- assert!(!is_empty_html(&html! { { "" } }));
538
- }
539
- }
@@ -10,7 +10,9 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use web_sys::MouseEvent;
13
+ use perspective_js::utils::global;
14
+ use wasm_bindgen::JsCast;
15
+ use web_sys::{HtmlElement, MouseEvent, Node};
14
16
  use yew::prelude::*;
15
17
 
16
18
  /// A single command row in a `ContextMenu`.
@@ -101,25 +103,162 @@ fn render_entry(entry: &ContextMenuEntry, on_close: &Callback<()>) -> Html {
101
103
  on_select,
102
104
  entries,
103
105
  } => {
104
- let onclick = {
105
- let on_select = on_select.clone();
106
- let on_close = on_close.clone();
107
- Callback::from(move |e: MouseEvent| {
108
- e.stop_propagation();
109
- if let Some(on_select) = &on_select {
110
- on_select.emit(());
111
- on_close.emit(());
112
- }
113
- })
114
- };
115
-
116
- let children = entries.iter().map(|entry| render_entry(entry, on_close));
117
106
  html! {
118
- <span class="context-menu-item has-submenu" {onclick}>
119
- { label.clone() }
120
- <div class="context-menu context-menu-submenu">{ for children }</div>
121
- </span>
107
+ <Submenu
108
+ label={label.clone()}
109
+ on_select={on_select.clone()}
110
+ entries={entries.clone()}
111
+ on_close={on_close.clone()}
112
+ />
122
113
  }
123
114
  },
124
115
  }
125
116
  }
117
+
118
+ #[derive(Properties, PartialEq)]
119
+ pub struct SubmenuProps {
120
+ pub label: String,
121
+ pub on_select: Option<Callback<()>>,
122
+ pub entries: Vec<ContextMenuEntry>,
123
+ pub on_close: Callback<()>,
124
+ }
125
+
126
+ pub enum SubmenuMsg {
127
+ Enter,
128
+ Leave,
129
+ Measure,
130
+ }
131
+
132
+ /// A hover-opened flyout row that opens toward whichever side of its parent
133
+ /// has room for it, tracked through the bubbling `mouseover`/`mouseout` pair
134
+ /// because Chrome withholds `mouseenter` from listeners on a shadow root.
135
+ pub struct Submenu {
136
+ flyout: NodeRef,
137
+ open: bool,
138
+ flip_x: bool,
139
+ flip_y: bool,
140
+ }
141
+
142
+ impl Submenu {
143
+ /// Which sides the flyout must open toward to stay on-screen, or `None`
144
+ /// while it is hidden and has no size to measure.
145
+ fn decide(&self) -> Option<(bool, bool)> {
146
+ let flyout = self.flyout.cast::<HtmlElement>()?;
147
+ let item = flyout.parent_element()?;
148
+ let flyout_rect = flyout.get_bounding_client_rect();
149
+ if flyout_rect.width() == 0.0 {
150
+ return None;
151
+ }
152
+
153
+ let window = global::window();
154
+ let inner_width = window.inner_width().unwrap().as_f64().unwrap();
155
+ let inner_height = window.inner_height().unwrap().as_f64().unwrap();
156
+ let item_rect = item.get_bounding_client_rect();
157
+ let over_x = inner_width < item_rect.right() + flyout_rect.width();
158
+ let over_y = inner_height < item_rect.top() - 1.0 + flyout_rect.height();
159
+ let flip_x = over_x && item_rect.left() > inner_width - item_rect.right();
160
+ let flip_y = over_y && item_rect.bottom() > inner_height - item_rect.top();
161
+ Some((flip_x, flip_y))
162
+ }
163
+ }
164
+
165
+ impl Component for Submenu {
166
+ type Message = SubmenuMsg;
167
+ type Properties = SubmenuProps;
168
+
169
+ fn create(_ctx: &Context<Self>) -> Self {
170
+ Self {
171
+ flyout: NodeRef::default(),
172
+ open: false,
173
+ flip_x: false,
174
+ flip_y: false,
175
+ }
176
+ }
177
+
178
+ fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
179
+ match msg {
180
+ SubmenuMsg::Enter => {
181
+ let changed = !self.open;
182
+ self.open = true;
183
+ changed
184
+ },
185
+ SubmenuMsg::Leave => {
186
+ let changed = self.open;
187
+ self.open = false;
188
+ changed
189
+ },
190
+ SubmenuMsg::Measure => match self.decide() {
191
+ Some((flip_x, flip_y)) => {
192
+ let changed = (flip_x, flip_y) != (self.flip_x, self.flip_y);
193
+ self.flip_x = flip_x;
194
+ self.flip_y = flip_y;
195
+ changed
196
+ },
197
+ None => false,
198
+ },
199
+ }
200
+ }
201
+
202
+ fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
203
+ if self.open
204
+ && self
205
+ .decide()
206
+ .is_some_and(|d| d != (self.flip_x, self.flip_y))
207
+ {
208
+ ctx.link().send_message(SubmenuMsg::Measure);
209
+ }
210
+ }
211
+
212
+ fn view(&self, ctx: &Context<Self>) -> Html {
213
+ let props = ctx.props();
214
+ let onclick = {
215
+ let on_select = props.on_select.clone();
216
+ let on_close = props.on_close.clone();
217
+ Callback::from(move |e: MouseEvent| {
218
+ e.stop_propagation();
219
+ if let Some(on_select) = &on_select {
220
+ on_select.emit(());
221
+ on_close.emit(());
222
+ }
223
+ })
224
+ };
225
+
226
+ let onmouseover = ctx.link().callback(|_: MouseEvent| SubmenuMsg::Enter);
227
+ let onmouseout = ctx.link().batch_callback({
228
+ let flyout = self.flyout.clone();
229
+ move |event: MouseEvent| {
230
+ let item = flyout.cast::<HtmlElement>()?.parent_element()?;
231
+ let related = event
232
+ .related_target()
233
+ .and_then(|target| target.dyn_into::<Node>().ok());
234
+
235
+ let stays = related.is_some_and(|node| item.contains(Some(&node)));
236
+ (!stays).then_some(SubmenuMsg::Leave)
237
+ }
238
+ });
239
+ let mut class = classes!("context-menu", "context-menu-submenu");
240
+ if self.open {
241
+ class.push("open");
242
+ }
243
+
244
+ if self.flip_x {
245
+ class.push("flip-x");
246
+ }
247
+
248
+ if self.flip_y {
249
+ class.push("flip-y");
250
+ }
251
+
252
+ let children = props
253
+ .entries
254
+ .iter()
255
+ .map(|entry| render_entry(entry, &props.on_close));
256
+
257
+ html! {
258
+ <span class="context-menu-item has-submenu" {onclick} {onmouseover} {onmouseout}>
259
+ { props.label.clone() }
260
+ <div {class} ref={self.flyout.clone()}>{ for children }</div>
261
+ </span>
262
+ }
263
+ }
264
+ }
@@ -12,13 +12,11 @@
12
12
 
13
13
  use std::cell::{Cell, Ref, RefCell};
14
14
  use std::future::Future;
15
- use std::iter::{Iterator, repeat_with};
16
15
  use std::rc::Rc;
17
16
 
18
17
  use futures::future::{join_all, select_all};
19
18
  use perspective_js::utils::{global, *};
20
19
  use wasm_bindgen::prelude::*;
21
- use wasm_bindgen::{JsCast, intern};
22
20
  use wasm_bindgen_futures::JsFuture;
23
21
  use yew::prelude::*;
24
22
 
@@ -119,31 +117,15 @@ impl FontLoaderProps {
119
117
  self.state.on_update.emit(());
120
118
 
121
119
  for (family, weight) in preload_fonts.iter() {
122
- let task = timeout_font_task(family, weight);
123
- let mut block_fonts: PromiseSet = vec![ApiFuture::new(task)];
124
-
125
- for entry in font_iter(global::document().fonts().values()) {
126
- let font_face = js_sys::Reflect::get(&entry, &intern("value").into())?
127
- .dyn_into::<web_sys::FontFace>()?;
128
-
129
- if family == &font_face.family().replace('"', "")
130
- && (weight == &font_face.weight()
131
- || (font_face.weight() == "normal" && weight == "400"))
132
- {
133
- block_fonts.push(ApiFuture::new(async move {
134
- Ok(JsFuture::from(font_face.loaded()?).await?)
135
- }));
136
- }
137
- }
120
+ let block_fonts: PromiseSet = vec![
121
+ ApiFuture::new(timeout_font_task(family, weight)),
122
+ ApiFuture::new(await_font(family.clone(), weight.clone())),
123
+ ];
138
124
 
139
125
  let fut = async { select_all(block_fonts).await.0 };
140
126
  block_promises.push(ApiFuture::new(fut))
141
127
  }
142
128
 
143
- if block_promises.len() != preload_fonts.len() {
144
- web_sys::console::warn_1(&format!("Missing preload fonts {:?}", preload_fonts).into());
145
- }
146
-
147
129
  let res = join_all(block_promises)
148
130
  .await
149
131
  .into_iter()
@@ -156,6 +138,25 @@ impl FontLoaderProps {
156
138
  }
157
139
  }
158
140
 
141
+ /// Resolve once the `@font-face`s needed to render [`FONT_TEST_SAMPLE`] in
142
+ /// `(family, weight)` are loaded — `FontFaceSet.load(spec, text)` both
143
+ /// triggers and awaits exactly those SUBSET faces, never a sibling
144
+ /// unicode-range face the page may never use. It resolves EMPTY while the
145
+ /// family has no registration at all (its stylesheet may still be fetching,
146
+ /// and Firefox registers lazily-parsed faces later than Chrome), so poll.
147
+ async fn await_font(family: String, weight: String) -> ApiResult<JsValue> {
148
+ let spec = format!("{weight} 12px \"{family}\"");
149
+ loop {
150
+ let fonts = global::document().fonts();
151
+ let loaded = JsFuture::from(fonts.load_with_text(&spec, FONT_TEST_SAMPLE)).await?;
152
+ if js_sys::Array::from(&loaded).length() > 0 {
153
+ return Ok(JsValue::UNDEFINED);
154
+ }
155
+
156
+ set_timeout(100).await?;
157
+ }
158
+ }
159
+
159
160
  fn timeout_font_task(
160
161
  family: &str,
161
162
  weight: &str,
@@ -198,16 +199,3 @@ fn parse_fonts(txt: &str) -> Vec<(String, String)> {
198
199
  .flatten()
199
200
  .collect::<Vec<_>>()
200
201
  }
201
-
202
- fn font_iter(
203
- iter: web_sys::FontFaceSetIterator,
204
- ) -> impl Iterator<Item = web_sys::FontFaceSetIteratorResult> {
205
- repeat_with(move || iter.next())
206
- .filter_map(|x| x.ok())
207
- .take_while(|entry| {
208
- !js_sys::Reflect::get(entry, &intern("done").into())
209
- .unwrap()
210
- .as_bool()
211
- .unwrap()
212
- })
213
- }
@@ -98,15 +98,3 @@ pub fn MirroredTextarea(props: &MirroredTextareaProps) -> Html {
98
98
  </div>
99
99
  }
100
100
  }
101
-
102
- #[cfg(test)]
103
- mod tests {
104
- use super::*;
105
-
106
- #[test]
107
- fn mirror_never_compares_equal() {
108
- let mirror = Mirror(Html::default());
109
- assert!(mirror != mirror.clone());
110
- assert!(Mirror(Html::default()) != Mirror(Html::default()));
111
- }
112
- }
@@ -384,92 +384,3 @@ pub fn multi_stop_gradient_selector(props: &MultiStopGradientProps) -> Html {
384
384
  </>
385
385
  }
386
386
  }
387
-
388
- #[cfg(test)]
389
- mod tests {
390
- use super::*;
391
-
392
- fn stop(color: &str, offset: f64) -> GradientStopSpec {
393
- GradientStopSpec {
394
- color: color.to_owned(),
395
- offset,
396
- }
397
- }
398
-
399
- #[test]
400
- fn sample_clamps_outside_stop_range() {
401
- let stops = vec![stop("#000000", 0.25), stop("#ff0000", 0.75)];
402
- assert_eq!(sample_gradient_hex(&stops, 0.0), "#000000");
403
- assert_eq!(sample_gradient_hex(&stops, 1.0), "#ff0000");
404
- }
405
-
406
- #[test]
407
- fn sample_interpolates_linearly() {
408
- let stops = vec![stop("#000000", 0.0), stop("#ff0000", 1.0)];
409
- assert_eq!(sample_gradient_hex(&stops, 0.5), "#800000");
410
-
411
- let stops = vec![
412
- stop("#000000", 0.0),
413
- stop("#ffffff", 0.5),
414
- stop("#ff0000", 1.0),
415
- ];
416
-
417
- assert_eq!(sample_gradient_hex(&stops, 0.25), "#808080");
418
- assert_eq!(sample_gradient_hex(&stops, 0.75), "#ff8080");
419
- }
420
-
421
- #[test]
422
- fn track_positions_pad_the_bar_edges() {
423
- assert_eq!(track_position(0.0), "calc(8px + (100% - 16px) * 0.0000)");
424
- assert_eq!(track_position(1.0), "calc(8px + (100% - 16px) * 1.0000)");
425
- assert_eq!(track_position(0.333), "calc(8px + (100% - 16px) * 0.3330)");
426
- }
427
-
428
- #[test]
429
- fn gradient_interpolates_on_padded_track() {
430
- let stops = vec![
431
- stop("#333333", 1.0),
432
- stop("#111111", 0.0),
433
- stop("#222222", 0.5),
434
- ];
435
-
436
- assert_eq!(
437
- css_gradient(&stops),
438
- format!(
439
- "background:linear-gradient(to right, #111111 {}, #222222 {}, #333333 {})",
440
- track_position(0.0),
441
- track_position(0.5),
442
- track_position(1.0)
443
- )
444
- );
445
- }
446
-
447
- #[test]
448
- fn equalize_centers_between_neighbors_or_edges() {
449
- let stops = vec![
450
- stop("#111111", 0.2),
451
- stop("#222222", 0.25),
452
- stop("#333333", 0.4),
453
- ];
454
-
455
- assert_eq!(equalized_offset(&stops, 1), (0.2 + 0.4) / 2.0);
456
-
457
- assert_eq!(equalized_offset(&stops, 0), 0.125);
458
- assert_eq!(equalized_offset(&stops, 2), (0.25 + 1.0) / 2.0);
459
-
460
- let single = vec![stop("#111111", 0.9)];
461
- assert_eq!(equalized_offset(&single, 0), 0.5);
462
- }
463
-
464
- #[test]
465
- fn sample_degenerate_cases() {
466
- assert_eq!(sample_gradient_hex(&[], 0.5), "#000000");
467
- let stops = vec![stop("#123456", 0.5)];
468
- assert_eq!(sample_gradient_hex(&stops, 0.1), "#123456");
469
- assert_eq!(sample_gradient_hex(&stops, 0.9), "#123456");
470
-
471
- let stops = vec![stop("red", 0.0), stop("blue", 1.0)];
472
- assert_eq!(sample_gradient_hex(&stops, 0.25), "red");
473
- assert_eq!(sample_gradient_hex(&stops, 0.75), "blue");
474
- }
475
- }
@@ -386,54 +386,3 @@ pub fn palette_selector(props: &PaletteProps) -> Html {
386
386
  </>
387
387
  }
388
388
  }
389
-
390
- #[cfg(test)]
391
- mod tests {
392
- use super::*;
393
-
394
- fn v(items: &[&str]) -> Vec<String> {
395
- items.iter().map(|x| x.to_string()).collect()
396
- }
397
-
398
- #[test]
399
- fn insertion_index_splits_at_centers_and_resolves_rows() {
400
- let centers = [(12.0, 12.0), (40.0, 12.0), (68.0, 12.0), (12.0, 40.0)];
401
- assert_eq!(insertion_index(&centers, 0.0, 12.0), 0);
402
- assert_eq!(insertion_index(&centers, 13.0, 12.0), 1);
403
- assert_eq!(insertion_index(&centers, 39.0, 12.0), 1);
404
- assert_eq!(insertion_index(&centers, 41.0, 12.0), 2);
405
- assert_eq!(insertion_index(&centers, 90.0, 12.0), 3);
406
- assert_eq!(insertion_index(&centers, 10.0, 41.0), 3);
407
- assert_eq!(insertion_index(&centers, 30.0, 41.0), 4);
408
- assert_eq!(insertion_index(&[], 5.0, 5.0), 0);
409
- }
410
-
411
- #[test]
412
- fn slot_from_display_maps_previewed_slots_back_to_original_order() {
413
- assert_eq!(slot_from_display(3, 2, 0), 3);
414
- assert_eq!(slot_from_display(2, 2, 0), 3);
415
- assert_eq!(slot_from_display(1, 2, 0), 2);
416
- assert_eq!(slot_from_display(0, 2, 0), 0);
417
- assert_eq!(slot_from_display(0, 0, 2), 0);
418
- assert_eq!(slot_from_display(1, 0, 2), 0);
419
- assert_eq!(slot_from_display(2, 0, 2), 1);
420
- assert_eq!(slot_from_display(3, 0, 2), 2);
421
- assert_eq!(slot_from_display(4, 0, 2), 4);
422
- assert_eq!(slot_from_display(1, 1, 1), 1);
423
- assert_eq!(slot_from_display(2, 1, 1), 1);
424
- assert_eq!(slot_from_display(3, 1, 1), 3);
425
- }
426
-
427
- #[test]
428
- fn reordered_moves_and_rejects_no_ops() {
429
- let values = v(&["a", "b", "c", "d"]);
430
- assert_eq!(reordered(&values, 0, 4), Some(v(&["b", "c", "d", "a"])));
431
- assert_eq!(reordered(&values, 3, 0), Some(v(&["d", "a", "b", "c"])));
432
- assert_eq!(reordered(&values, 0, 2), Some(v(&["b", "a", "c", "d"])));
433
- assert_eq!(reordered(&values, 2, 1), Some(v(&["a", "c", "b", "d"])));
434
- assert_eq!(reordered(&values, 1, 1), None);
435
- assert_eq!(reordered(&values, 1, 2), None);
436
- assert_eq!(reordered(&values, 4, 0), None);
437
- assert_eq!(reordered(&values, 0, 5), None);
438
- }
439
- }
@@ -135,19 +135,13 @@ impl PortalModal {
135
135
 
136
136
  fn position_against_target(&mut self, target: &HtmlElement) {
137
137
  let target_rect = target.get_bounding_client_rect();
138
- let height = target_rect.height();
139
- let width = target_rect.width();
140
- let top = target_rect.top();
141
- let left = target_rect.left();
142
-
143
138
  if !self.visible {
144
- self.top = top + height - 1.0;
145
- self.left = left;
146
- self.visible = false;
139
+ self.top = target_rect.top() + target_rect.height() - 1.0;
140
+ self.left = target_rect.left();
147
141
  } else {
148
- let anchor = calc_relative_position(&self.host, top, left, height, width);
149
- self.anchor.set(anchor);
150
142
  let modal_rect = self.host.get_bounding_client_rect();
143
+ let anchor = calc_relative_position(&target_rect, &modal_rect);
144
+ self.anchor.set(anchor);
151
145
  let (new_top, new_left) = calc_anchor_position(anchor, &target_rect, &modal_rect);
152
146
  self.top = new_top;
153
147
  self.left = new_left;
@@ -15,14 +15,13 @@
15
15
  mod blob;
16
16
  mod download;
17
17
  mod dragdrop;
18
+ mod mouse;
18
19
  mod request_animation_frame;
19
20
  mod selection;
20
21
 
21
- #[cfg(test)]
22
- mod tests;
23
-
24
22
  pub use self::blob::*;
25
23
  pub use self::download::*;
26
24
  pub use self::dragdrop::*;
25
+ pub use self::mouse::*;
27
26
  pub use self::request_animation_frame::*;
28
27
  pub use self::selection::*;