flexdesk 0.16.0 → 0.17.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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -2,14 +2,25 @@
2
2
  * HelpModal - Modal dialog for displaying help content
3
3
  *
4
4
  * Features:
5
- * - Markdown rendering with syntax highlighting
6
- * - Table of contents navigation
5
+ * - Topics as trusted HTML (`topic.html`) or as markdown (`topic.markdown`),
6
+ * rendered safe by `renderMarkdown` with the provider's `markdownOptions`
7
+ * (a highlighter, math, extension rules)
8
+ * - A table of contents for a topic with two or more headings, markdown or
9
+ * html alike: its headings, listed and numbered, the one in view marked,
10
+ * a click that scrolls to one (`createDocumentOutline`)
7
11
  * - Topic search
8
12
  * - Category browsing
9
13
  * - Keyboard navigation (Escape to close)
10
14
  */
11
15
 
12
16
  import { helpProvider, helpCategories, helpCopy } from './help_registry.js';
17
+ import { renderMarkdown } from '../ui/utils/markdown.js';
18
+ import { createDocumentOutline } from '../ui/components/document_outline.js';
19
+
20
+ /** The headings a topic's table of contents lists. */
21
+ const TOC_HEADINGS = 'h1, h2, h3, h4, h5, h6';
22
+ /** Fewer headings than this, and a topic shows no table of contents. */
23
+ const TOC_MIN_HEADINGS = 2;
13
24
 
14
25
  // ═══════════════════════════════════════════════════════════════════════════
15
26
  // HelpModal Class
@@ -33,14 +44,22 @@ export class HelpModal {
33
44
  this._modal = null;
34
45
  this._currentTopic = null;
35
46
  this._searchQuery = '';
47
+ this._fallback = null;
48
+ this._toc = null;
36
49
  this._boundKeyHandler = this._handleKeyDown.bind(this);
37
50
  }
38
51
 
39
52
  /**
40
53
  * Open the help modal.
41
54
  * @param {string} [topicId] - Initial topic to display
55
+ * @param {Object} [options]
56
+ * @param {{title: string, html: string}} [options.fallback] - Shown for
57
+ * `topicId` when the provider has no such topic (its `loadTopic` answers
58
+ * null). A library-owned topic, such as the palette's keyboard shortcuts,
59
+ * uses it so that an application's own topic of that id still wins.
42
60
  */
43
- async open(topicId = null) {
61
+ async open(topicId = null, { fallback = null } = {}) {
62
+ this._fallback = topicId && fallback ? { id: topicId, topic: fallback } : null;
44
63
  // Close any existing modal
45
64
  if (_activeModal && _activeModal !== this) {
46
65
  _activeModal.close();
@@ -71,6 +90,7 @@ export class HelpModal {
71
90
  * Close the help modal.
72
91
  */
73
92
  close() {
93
+ this._disposeToc();
74
94
  if (this._overlay) {
75
95
  document.removeEventListener('keydown', this._boundKeyHandler);
76
96
  this._overlay.remove();
@@ -165,15 +185,15 @@ export class HelpModal {
165
185
  const icon = this._categories[category]?.icon || 'folder';
166
186
 
167
187
  return `
168
- <div class="help-category" data-category="${category}">
188
+ <div class="help-category" data-category="${escapeHtml(category)}">
169
189
  <div class="help-category__header">
170
- <span class="material-symbols-outlined">${icon}</span>
171
- <span>${label}</span>
190
+ <span class="material-symbols-outlined">${escapeHtml(icon)}</span>
191
+ <span>${escapeHtml(label)}</span>
172
192
  </div>
173
193
  <ul class="help-category__topics">
174
194
  ${topics.map(topic => `
175
- <li class="help-topic-item" data-topic="${topic.id}">
176
- ${topic.title}
195
+ <li class="help-topic-item" data-topic="${escapeHtml(topic.id)}">
196
+ ${escapeHtml(topic.title)}
177
197
  </li>
178
198
  `).join('')}
179
199
  </ul>
@@ -200,6 +220,7 @@ export class HelpModal {
200
220
  * @private
201
221
  */
202
222
  _showIndex() {
223
+ this._disposeToc();
203
224
  const content = this._modal.querySelector('.help-modal__content');
204
225
  const categories = this._service.getCategories();
205
226
 
@@ -222,15 +243,15 @@ export class HelpModal {
222
243
  const icon = this._categories[category]?.icon || 'folder';
223
244
 
224
245
  return `
225
- <div class="help-index__category" data-category="${category}">
246
+ <div class="help-index__category" data-category="${escapeHtml(category)}">
226
247
  <div class="help-index__category-header">
227
- <span class="material-symbols-outlined">${icon}</span>
228
- <span>${label}</span>
248
+ <span class="material-symbols-outlined">${escapeHtml(icon)}</span>
249
+ <span>${escapeHtml(label)}</span>
229
250
  </div>
230
251
  <ul class="help-index__topics">
231
252
  ${topics.slice(0, 3).map(topic => `
232
- <li class="help-index__topic" data-topic="${topic.id}">
233
- ${topic.title}
253
+ <li class="help-index__topic" data-topic="${escapeHtml(topic.id)}">
254
+ ${escapeHtml(topic.title)}
234
255
  </li>
235
256
  `).join('')}
236
257
  ${topics.length > 3 ? `<li class="help-index__more">+${topics.length - 3} more</li>` : ''}
@@ -256,6 +277,7 @@ export class HelpModal {
256
277
  * @private
257
278
  */
258
279
  async _loadTopic(topicId) {
280
+ this._disposeToc();
259
281
  const content = this._modal.querySelector('.help-modal__content');
260
282
  content.innerHTML = '<div class="help-content__loading">Loading...</div>';
261
283
 
@@ -265,9 +287,23 @@ export class HelpModal {
265
287
  });
266
288
 
267
289
  try {
268
- const topic = await this._service.loadTopic(topicId);
290
+ const topic = (await this._service.loadTopic(topicId))
291
+ ?? (this._fallback?.id === topicId ? this._fallback.topic : null);
292
+ // Closed (or closed and opened again) while the topic loaded.
293
+ if (!this._modal?.contains(content)) return;
269
294
  this._currentTopic = topicId;
270
295
 
296
+ if (!topic) {
297
+ // An id nobody provides is not a load failure: say so plainly.
298
+ content.innerHTML = `
299
+ <div class="help-content__error help-content__error--not-found">
300
+ <span class="material-symbols-outlined">search_off</span>
301
+ <p>Help topic not found.</p>
302
+ </div>
303
+ `;
304
+ return;
305
+ }
306
+
271
307
  content.innerHTML = `
272
308
  <div class="help-content__article">
273
309
  <div class="help-content__breadcrumb">
@@ -275,10 +311,11 @@ export class HelpModal {
275
311
  <span class="material-symbols-outlined">home</span>
276
312
  </a>
277
313
  <span class="help-breadcrumb__separator">/</span>
278
- <span class="help-breadcrumb__title">${topic.title}</span>
314
+ <span class="help-breadcrumb__title">${escapeHtml(topic.title)}</span>
279
315
  </div>
316
+ <div class="help-content__toc" hidden></div>
280
317
  <article class="help-article">
281
- ${topic.html}
318
+ ${this._articleHtml(topic)}
282
319
  </article>
283
320
  </div>
284
321
  `;
@@ -288,6 +325,8 @@ export class HelpModal {
288
325
  e.preventDefault();
289
326
  this._showIndex();
290
327
  });
328
+
329
+ this._buildToc(content);
291
330
  } catch (error) {
292
331
  content.innerHTML = `
293
332
  <div class="help-content__error">
@@ -298,6 +337,54 @@ export class HelpModal {
298
337
  }
299
338
  }
300
339
 
340
+ /**
341
+ * The article body: a markdown topic rendered safe, else the provider's
342
+ * own HTML, which is trusted by contract.
343
+ * @private
344
+ */
345
+ _articleHtml(topic) {
346
+ if (typeof topic.markdown === 'string') {
347
+ return renderMarkdown(topic.markdown, this._service.markdownOptions ?? {});
348
+ }
349
+ return topic.html ?? '';
350
+ }
351
+
352
+ /**
353
+ * The topic's table of contents: one entry per heading in the article, in
354
+ * document order, its level the heading's. Built only when the article
355
+ * has `TOC_MIN_HEADINGS` or more; the outline resolves each entry to its
356
+ * heading ELEMENT, so the provider's markup needs no ids and the modal
357
+ * adds none.
358
+ * @param {HTMLElement} content - The scrolling content pane.
359
+ * @private
360
+ */
361
+ _buildToc(content) {
362
+ this._disposeToc();
363
+ const host = content.querySelector('.help-content__toc');
364
+ const headings = [...content.querySelectorAll(`.help-article :is(${TOC_HEADINGS})`)];
365
+ if (!host || headings.length < TOC_MIN_HEADINGS) return;
366
+ const byId = new Map(headings.map((heading, index) => [`heading-${index}`, heading]));
367
+ const entries = [...byId].map(([id, heading]) => ({
368
+ id,
369
+ level: Number(heading.tagName.slice(1)),
370
+ label: heading.textContent.trim(),
371
+ }));
372
+ host.hidden = false;
373
+ this._toc = createDocumentOutline(host, {
374
+ entries,
375
+ numbering: true,
376
+ scrollContainer: content,
377
+ resolveTarget: (entry) => byId.get(entry.id) ?? null,
378
+ label: 'Contents',
379
+ });
380
+ }
381
+
382
+ /** @private */
383
+ _disposeToc() {
384
+ this._toc?.dispose();
385
+ this._toc = null;
386
+ }
387
+
301
388
  /**
302
389
  * Handle search input.
303
390
  * @param {string} query - Search query
@@ -312,13 +399,14 @@ export class HelpModal {
312
399
  return;
313
400
  }
314
401
 
402
+ this._disposeToc();
315
403
  const results = this._service.searchTopics(query);
316
404
 
317
405
  if (results.length === 0) {
318
406
  content.innerHTML = `
319
407
  <div class="help-search-results">
320
408
  <h2>Search Results</h2>
321
- <p class="help-search-empty">No results found for "${query}"</p>
409
+ <p class="help-search-empty">No results found for "${escapeHtml(query)}"</p>
322
410
  </div>
323
411
  `;
324
412
  return;
@@ -327,12 +415,12 @@ export class HelpModal {
327
415
  content.innerHTML = `
328
416
  <div class="help-search-results">
329
417
  <h2>Search Results</h2>
330
- <p class="help-search-count">${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"</p>
418
+ <p class="help-search-count">${results.length} result${results.length !== 1 ? 's' : ''} for "${escapeHtml(query)}"</p>
331
419
  <ul class="help-search-list">
332
420
  ${results.map(topic => `
333
- <li class="help-search-item" data-topic="${topic.id}">
334
- <span class="help-search-item__title">${topic.title}</span>
335
- <span class="help-search-item__category">${this._categories[topic.category]?.label || topic.category}</span>
421
+ <li class="help-search-item" data-topic="${escapeHtml(topic.id)}">
422
+ <span class="help-search-item__title">${escapeHtml(topic.title)}</span>
423
+ <span class="help-search-item__category">${escapeHtml(this._categories[topic.category]?.label || topic.category)}</span>
336
424
  </li>
337
425
  `).join('')}
338
426
  </ul>
@@ -361,14 +449,28 @@ export class HelpModal {
361
449
  /**
362
450
  * Static method to open help modal.
363
451
  * @param {string} [topicId] - Initial topic
452
+ * @param {Object} [options] - As for the instance `open`.
364
453
  * @returns {HelpModal}
365
454
  */
366
- static open(topicId = null) {
455
+ static open(topicId = null, options = {}) {
367
456
  const modal = new HelpModal();
368
- modal.open(topicId);
457
+ modal.open(topicId, options);
369
458
  return modal;
370
459
  }
371
460
 
461
+ /**
462
+ * Close the open help modal, or open the index when none is open — what
463
+ * the `?` shortcut does.
464
+ * @returns {HelpModal|null} the modal opened, or null when one was closed
465
+ */
466
+ static toggle() {
467
+ if (_activeModal) {
468
+ _activeModal.close();
469
+ return null;
470
+ }
471
+ return HelpModal.open();
472
+ }
473
+
372
474
  /**
373
475
  * Get the currently active modal.
374
476
  * @returns {HelpModal|null}
@@ -379,31 +481,55 @@ export class HelpModal {
379
481
  }
380
482
 
381
483
  // ═══════════════════════════════════════════════════════════════════════════
382
- // Global Help Shortcut
484
+ // Helpers
383
485
  // ═══════════════════════════════════════════════════════════════════════════
384
486
 
385
487
  /**
386
- * Open help modal with the `?` key. (F1 is reserved for top-nav Home.)
488
+ * Text for an innerHTML template. Everything the modal interpolates — the
489
+ * search query the user typed, and the topic ids, titles and categories the
490
+ * provider hands back — is TEXT; only `topic.html` is markup, by contract
491
+ * (and `topic.markdown` becomes markup through `renderMarkdown`).
492
+ * Quotes are escaped too, because ids and categories land in attributes.
387
493
  */
388
- function setupGlobalHelpShortcut() {
389
- document.addEventListener('keydown', (event) => {
390
- if (event.key !== '?') return;
391
- // Don't hijack `?` typed into a field (search boxes, editors).
392
- const t = event.target;
393
- if (t?.closest?.(
394
- 'input, textarea, select, [contenteditable="true"]')) return;
395
- event.preventDefault();
396
- if (_activeModal) {
397
- _activeModal.close();
398
- } else {
399
- HelpModal.open();
400
- }
401
- });
494
+ function escapeHtml(value) {
495
+ if (value === null || value === undefined) return '';
496
+ return String(value)
497
+ .replace(/&/g, '&amp;')
498
+ .replace(/</g, '&lt;')
499
+ .replace(/>/g, '&gt;')
500
+ .replace(/"/g, '&quot;')
501
+ .replace(/'/g, '&#39;');
402
502
  }
403
503
 
404
- // Initialize global shortcut
405
- if (typeof document !== 'undefined') {
406
- setupGlobalHelpShortcut();
504
+ // ═══════════════════════════════════════════════════════════════════════════
505
+ // The `?` shortcut — installed, never imported
506
+ // ═══════════════════════════════════════════════════════════════════════════
507
+
508
+ /**
509
+ * Bind `?` (outside editable fields) to `HelpModal.toggle()` on `target`, and
510
+ * return the disposer that takes it off again.
511
+ *
512
+ * Importing this module binds nothing. It used to add a `document` listener as
513
+ * a side effect of the import, which nobody could remove: a second shell in the
514
+ * same page, or an application with its own `?`, got two handlers. A
515
+ * `createShell()` shell binds `?` through its keymap (`installKeymap`), and
516
+ * `shell.dispose()` takes it off; this is for a page that uses the help modal
517
+ * without the shell.
518
+ *
519
+ * @param {object} [opts]
520
+ * @param {EventTarget} [opts.target=document]
521
+ * @returns {() => void}
522
+ */
523
+ export function installHelpShortcut({ target = document } = {}) {
524
+ const onKeyDown = (event) => {
525
+ if (event.defaultPrevented || event.key !== '?') return;
526
+ // Don't hijack `?` typed into a field (search boxes, editors).
527
+ if (event.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
528
+ event.preventDefault();
529
+ HelpModal.toggle();
530
+ };
531
+ target.addEventListener('keydown', onKeyDown);
532
+ return () => target.removeEventListener('keydown', onKeyDown);
407
533
  }
408
534
 
409
535
  export default HelpModal;
@@ -13,8 +13,19 @@
13
13
  * @typedef {Object} HelpProvider
14
14
  * @property {() => string[]} getCategories
15
15
  * @property {(category: string) => Array<{id: string, title: string}>} getTopicsByCategory
16
- * @property {(id: string) => Promise<Object|null>} loadTopic
16
+ * @property {(id: string) => Promise<HelpTopic|null>} loadTopic
17
17
  * @property {(query: string) => Array<Object>} searchTopics
18
+ * @property {Object} [markdownOptions] What the modal passes to
19
+ * `renderMarkdown` for a markdown topic: `highlight`, `math`, `allowHtml`,
20
+ * `inlineRules`, `blockRules`.
21
+ *
22
+ * @typedef {Object} HelpTopic
23
+ * @property {string} title Text.
24
+ * @property {string} [markdown] The article as markdown, rendered safe by
25
+ * `renderMarkdown`: raw HTML is escaped and only http(s), mailto and #anchor
26
+ * links go anywhere. Used when present.
27
+ * @property {string} [html] The article as pre-rendered, TRUSTED HTML,
28
+ * inserted verbatim. Never put unsanitised user content here.
18
29
  *
19
30
  * @typedef {Object.<string, {label: string, icon: string}>} HelpCategories
20
31
  * Keyed by category id; ITERATION ORDER IS DISPLAY ORDER.
File without changes
@@ -0,0 +1,117 @@
1
+ /**
2
+ * file_picker.js — let the user choose a file to open, and read it.
3
+ *
4
+ * A native window (pywebview, Photino) offers a real open dialog through the
5
+ * optional `host.dialogs.openFile` capability; a browser tab has none. pickFile()
6
+ * asks the host first and, when the host has no `openFile` or answers null
7
+ * ("no dialog behind it", per host.js), opens the browser's own file chooser
8
+ * through a transient `<input type="file">`. The caller gets ONE result shape
9
+ * either way:
10
+ *
11
+ * {ok: true, name, path?, text} encoding 'text' (the default)
12
+ * {ok: true, name, path?, data} encoding 'base64': the file's bytes, base64
13
+ * {ok: false, cancelled: true} the user closed the chooser: say nothing
14
+ * {ok: false, error} show it to the user
15
+ *
16
+ * `path` comes only from a native host; a browser never reveals one.
17
+ */
18
+
19
+ const ENCODINGS = new Set(['text', 'base64']);
20
+
21
+ /**
22
+ * `accept` as a list of extensions or MIME types: an array is kept, a string
23
+ * is split on commas (the `<input accept>` form), each entry trimmed and the
24
+ * empty ones dropped. `'.json, .csv'` → `['.json', '.csv']`.
25
+ */
26
+ function acceptList(accept) {
27
+ const parts = Array.isArray(accept) ? accept : (accept ? String(accept).split(',') : []);
28
+ return parts.map((a) => String(a).trim()).filter(Boolean);
29
+ }
30
+
31
+ /** `accept` as the `<input accept>` attribute wants it: comma-separated. */
32
+ function acceptAttr(accept) {
33
+ return acceptList(accept).join(',');
34
+ }
35
+
36
+ /** Read `file` as text or as base64, with the FileReader of the file's own window. */
37
+ function readFile(file, encoding, FileReaderCtor) {
38
+ return new Promise((resolve) => {
39
+ const reader = new FileReaderCtor();
40
+ reader.onload = () => {
41
+ const name = file.name;
42
+ if (encoding === 'base64') {
43
+ const url = String(reader.result ?? '');
44
+ resolve({ ok: true, name, data: url.slice(url.indexOf(',') + 1) });
45
+ } else {
46
+ resolve({ ok: true, name, text: String(reader.result ?? '') });
47
+ }
48
+ };
49
+ reader.onerror = () => resolve({ ok: false, error: String(reader.error?.message || reader.error || 'The file could not be read.') });
50
+ if (encoding === 'base64') reader.readAsDataURL(file);
51
+ else reader.readAsText(file);
52
+ });
53
+ }
54
+
55
+ /**
56
+ * The browser's file chooser: a hidden `<input type="file">`, clicked, removed
57
+ * again once it answers. Cancelling is the input's `cancel` event (every
58
+ * current engine fires it; an engine that does not leaves the promise pending
59
+ * until the user does choose a file).
60
+ */
61
+ function browserChooser({ accept, encoding, doc }) {
62
+ const FileReaderCtor = doc?.defaultView?.FileReader ?? globalThis.FileReader;
63
+ if (!doc?.body || typeof FileReaderCtor !== 'function') {
64
+ return Promise.resolve({ ok: false, error: 'No file chooser is available here.' });
65
+ }
66
+ return new Promise((resolve) => {
67
+ const input = doc.createElement('input');
68
+ input.type = 'file';
69
+ const a = acceptAttr(accept);
70
+ if (a) input.accept = a;
71
+ input.style.display = 'none';
72
+ let done = false;
73
+ const finish = (result) => {
74
+ if (done) return;
75
+ done = true;
76
+ input.removeEventListener('change', onChange);
77
+ input.removeEventListener('cancel', onCancel);
78
+ input.remove();
79
+ resolve(result);
80
+ };
81
+ const onChange = () => {
82
+ const file = input.files && input.files[0];
83
+ if (!file) { finish({ ok: false, cancelled: true }); return; }
84
+ readFile(file, encoding, FileReaderCtor).then(finish);
85
+ };
86
+ const onCancel = () => finish({ ok: false, cancelled: true });
87
+ input.addEventListener('change', onChange);
88
+ input.addEventListener('cancel', onCancel);
89
+ doc.body.appendChild(input);
90
+ input.click();
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Let the user choose one file and read it.
96
+ *
97
+ * @param {object} host a Host; its optional `dialogs.openFile` is asked first
98
+ * @param {object} [opts]
99
+ * @param {string[]|string} [opts.accept] extensions or MIME types, e.g. ['.json', '.csv'];
100
+ * a string is comma-separated ('.json,.csv'). The
101
+ * native host always receives the list.
102
+ * @param {string|null} [opts.kind] a label for the native dialog's filter (e.g. 'json')
103
+ * @param {'text'|'base64'} [opts.encoding='text'] text, or the bytes as base64
104
+ * @param {Document} [opts.document] where the browser chooser goes (default: the page's)
105
+ * @returns {Promise<{ok: true, name: string, path?: string, text?: string, data?: string}
106
+ * | {ok: false, cancelled: true} | {ok: false, error: string}>}
107
+ */
108
+ export async function pickFile(host, { accept = [], kind = null, encoding = 'text', document: doc } = {}) {
109
+ if (!ENCODINGS.has(encoding)) throw new TypeError(`pickFile: unknown encoding '${encoding}' (use 'text' or 'base64')`);
110
+ if (typeof host?.dialogs?.openFile === 'function') {
111
+ let r = null;
112
+ try { r = await host.dialogs.openFile({ accept: acceptList(accept), kind, encoding }); }
113
+ catch { r = null; } // a failing host degrades like an absent one
114
+ if (r && typeof r === 'object') return r;
115
+ }
116
+ return browserChooser({ accept, encoding, doc: doc ?? (typeof document !== 'undefined' ? document : null) });
117
+ }
package/src/host/host.js CHANGED
@@ -22,11 +22,19 @@ export const HOST_CONTRACT = Object.freeze({
22
22
  // `chrome()` answers 'native' or 'custom', or a promise of one.
23
23
  optional: Object.freeze(['startNativeDrag', 'startNativeResize']),
24
24
  }),
25
- dialogs: Object.freeze({ required: Object.freeze(['saveFile']), optional: Object.freeze([]) }),
25
+ // `openFile({accept, kind, encoding})` answers {ok: true, name, path?, text | data},
26
+ // {ok: false, cancelled: true}, {ok: false, error}, or null when there is
27
+ // no dialog behind it. Optional: pickFile() (file_picker.js) falls back to
28
+ // the browser's file chooser without it.
29
+ dialogs: Object.freeze({ required: Object.freeze(['saveFile']), optional: Object.freeze(['openFile']) }),
26
30
  state: Object.freeze({ required: Object.freeze(['read', 'write']), optional: Object.freeze([]) }),
27
31
  // `openExternal(url)` hands a web address to the OS's default browser and
28
32
  // resolves true when it did. See external_links.js.
29
33
  shell: Object.freeze({ required: Object.freeze(['openExternal']), optional: Object.freeze([]) }),
34
+ // `list(kind)` answers the starter entries `[{id, kind, label, description?,
35
+ // fields?}]` for one kind (every kind when `kind` is null), or a promise of
36
+ // them. Backs mountGalleryPicker() when it is given no `entries`.
37
+ gallery: Object.freeze({ required: Object.freeze(['list']), optional: Object.freeze([]) }),
30
38
  });
31
39
 
32
40
  /**
@@ -52,7 +60,7 @@ export function validateCapability(name, cap) {
52
60
  * Throws at boot on an incomplete capability — a half-wired host is a bug,
53
61
  * and a bug at boot beats a silent degrade at click time.
54
62
  *
55
- * @param {{ window?: object, dialogs?: object, state?: object, shell?: object }} caps
63
+ * @param {{ window?: object, dialogs?: object, state?: object, shell?: object, gallery?: object }} caps
56
64
  * @returns {Readonly<object>} the Host
57
65
  */
58
66
  export function createHost(caps = {}) {
@@ -182,6 +182,15 @@ export function createPhotinoHost(opts = {}) {
182
182
  catch (e) { warn('dialogs', 'saveFile', e); }
183
183
  return r && typeof r === 'object' ? r : null;
184
184
  },
185
+ // `{ok: true, name, path?, text | data}` | `{ok: false, cancelled: true}` |
186
+ // `{ok: false, error}`, or null when the host refused: pickFile() then
187
+ // opens the browser's chooser. No timeout, for the same reason as saveFile.
188
+ async openFile({ accept = [], kind = null, encoding = 'text' } = {}) {
189
+ let r = null;
190
+ try { r = await request('dialogs', 'openFile', [{ accept, kind, encoding }], { wait: 0 }); }
191
+ catch (e) { warn('dialogs', 'openFile', e); }
192
+ return r && typeof r === 'object' ? r : null;
193
+ },
185
194
  },
186
195
  shell: {
187
196
  // True when the native side opened it in the system browser; false
@@ -14,9 +14,10 @@ host → page {"event": "window.maximizedChanged", "value": true}
14
14
  ```
15
15
 
16
16
  - Every request gets exactly one reply with the same `id`. A request that gets
17
- none is given up after 10 s (`timeoutMs`). `dialogs.saveFile` is the
18
- exception: the page waits for it without a timeout, because the dialog stays
19
- open as long as the user takes. Always answer it.
17
+ none is given up after 10 s (`timeoutMs`). `dialogs.saveFile` and
18
+ `dialogs.openFile` are the exceptions: the page waits for them without a
19
+ timeout, because the dialog stays open as long as the user takes. Always
20
+ answer them.
20
21
  - `ok: false` means "I can't": the adapter logs it and degrades, exactly as it
21
22
  does with pywebview. Use it for unknown ops and for failures.
22
23
  - A message with no `id` and an `event` name is an event. The adapter hands it
@@ -40,6 +41,7 @@ host → page {"event": "window.maximizedChanged", "value": true}
40
41
  | `window.startDrag` | `[]` | anything, **after mouse-up** | custom chrome only: the OS move loop (below) |
41
42
  | `window.startResize` | `[edge]`: `n` `s` `e` `w` `ne` `nw` `se` `sw` | anything, **after mouse-up** | custom chrome only: the OS size loop (below) |
42
43
  | `dialogs.saveFile` | `[{data, filename, kind}]` | see below | `ShowSaveFile(...)` + write |
44
+ | `dialogs.openFile` | `[{accept, kind, encoding}]` | see below | `ShowOpenFile(...)` + read |
43
45
  | `shell.openExternal` | `[url]` | `true` when opened | the OS's default browser (below) |
44
46
  | `state.read` | `[key]` | the stored value, or `null` | a file per key |
45
47
  | `state.write` | `[key, value]` | `true` | a file per key |
@@ -113,6 +115,22 @@ as the reply's `value` (the envelope's own `ok` stays `true`). A reply with the
113
115
  envelope's `ok: false`, or none at all, makes `saveFile` resolve `null`, and the
114
116
  caller downloads the file the browser way instead.
115
117
 
118
+ **`dialogs.openFile`** (0.17.0, optional). `accept` is a list of extensions or
119
+ MIME types to filter by (`[".json"]`, may be empty), `kind` a label for the
120
+ filter or `null`, and `encoding` `"text"` or `"base64"`. Show an open dialog,
121
+ read the chosen file, and reply with one of:
122
+
123
+ ```
124
+ {"ok": true, "name": "model.json", "path": "C:\\Users\\me\\model.json", "text": "{...}"} encoding "text": UTF-8
125
+ {"ok": true, "name": "pic.png", "path": "C:\\Users\\me\\pic.png", "data": "iVBO..."} encoding "base64": the bytes
126
+ {"ok": false, "cancelled": true}
127
+ {"ok": false, "error": "The file is in use."}
128
+ ```
129
+
130
+ as the reply's `value`. A reply with the envelope's `ok: false` (an older
131
+ native side without the op) makes `openFile` resolve `null`, and `pickFile()`
132
+ opens the browser's file chooser instead.
133
+
116
134
  **`state`.** `value` is any JSON value; store it and give it back unchanged. A
117
135
  string value that holds JSON is parsed on the way back, for hosts that store
118
136
  the text.
@@ -115,6 +115,16 @@ export function createPywebviewHost({ bridgeRef = null, resolvePath = (k) => `${
115
115
  // Blob download, never report a failed save. Covered by
116
116
  // tests/ui/test_host_save_fallback.mjs.
117
117
  saveFile: ({ data, filename, kind }) => call('save_file_dialog', [data, filename, kind]),
118
+ // POSITIONAL on the wire like save_file_dialog: read_file_dialog(accept,
119
+ // kind, encoding), where `accept` is a list of extensions or MIME types
120
+ // (may be empty), `kind` a label for the filter or null, and `encoding`
121
+ // 'text' or 'base64'. The reply is openFile's result shape (host.js);
122
+ // null when the transport has no read_file_dialog, and pickFile() then
123
+ // opens the browser's file chooser instead. See the README host contract.
124
+ async openFile({ accept = [], kind = null, encoding = 'text' } = {}) {
125
+ const r = await call('read_file_dialog', [accept, kind, encoding]);
126
+ return r && typeof r === 'object' ? r : null;
127
+ },
118
128
  },
119
129
  state: {
120
130
  async read(key) {