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.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsView — the settings page, drawn from a settings store's schema.
|
|
3
|
+
*
|
|
4
|
+
* const view = new SettingsView({ store, eventBus }); // the bus the store emits on
|
|
5
|
+
* view.mount(host);
|
|
6
|
+
* view.selectCategory('window');
|
|
7
|
+
* view.setQuery('shadow');
|
|
8
|
+
* view.dispose();
|
|
9
|
+
*
|
|
10
|
+
* // or as tile content:
|
|
11
|
+
* createShell({ content: { settings: settingsViewContent({ store, eventBus }) } });
|
|
12
|
+
*
|
|
13
|
+
* `store` is a `createSettingsStore()` handle, or the app-global binding's own
|
|
14
|
+
* (`getSettingsStore()` in `@flexdesk/core`). The view knows no setting: every
|
|
15
|
+
* category, group, label, description and control comes from the store's
|
|
16
|
+
* `getCategories()` and `getSchema()`.
|
|
17
|
+
*
|
|
18
|
+
* THE LAYOUT. A sidebar with a filter box, the categories in their
|
|
19
|
+
* `CategoryDef.order` and *Reset all*; beside it the chosen category's
|
|
20
|
+
* settings under its title, description and *Reset category*, grouped by
|
|
21
|
+
* `SettingDef.group` in schema order. Each row shows its label, a *Modified*
|
|
22
|
+
* marker while its value differs from its default, its description, its path,
|
|
23
|
+
* its control and a reset button. Text typed in the filter searches every
|
|
24
|
+
* category's labels, descriptions and paths, and the matches are listed
|
|
25
|
+
* under their category and group.
|
|
26
|
+
*
|
|
27
|
+
* THE CONTROLS, one per `SettingDef.type`:
|
|
28
|
+
* boolean a ToggleField
|
|
29
|
+
* select a select over `options`
|
|
30
|
+
* number a number box with `min`, `max` and `step`; a value written is
|
|
31
|
+
* snapped to the step (counted from `min`) and clamped to the range,
|
|
32
|
+
* and a box left empty or unreadable goes back to the value
|
|
33
|
+
* text a text box written `textDebounceMs` after the last keystroke,
|
|
34
|
+
* or at once on Enter or blur; emptied, it writes `null` when the
|
|
35
|
+
* default is `null`, else `''`
|
|
36
|
+
* colorList a ColorListField, written on every committed edit while every
|
|
37
|
+
* row holds a colour
|
|
38
|
+
* A type the view does not know shows the value as text, read only.
|
|
39
|
+
*
|
|
40
|
+
* LIVE, BOTH WAYS. A control writes through `store.set()`. The view listens
|
|
41
|
+
* on `eventBus` for `settings:<path>:changed` for every path in the schema,
|
|
42
|
+
* for `settings:changed` with a null path (the resets) and for
|
|
43
|
+
* `settings:registered` (a slice registered after mount: its categories join
|
|
44
|
+
* the sidebar and its settings their category, without a remount). A value
|
|
45
|
+
* changed elsewhere moves its control and its marker. An edit still being
|
|
46
|
+
* typed (a text before its debounce, digits before 'change') is written
|
|
47
|
+
* before the view redraws or syncs its rows, so it is never dropped.
|
|
48
|
+
*
|
|
49
|
+
* RESETS. A row's reset button resets that setting. *Reset category* and
|
|
50
|
+
* *Reset all* ask `confirm(…)` first (default `openConfirm`) and change
|
|
51
|
+
* nothing unless it resolves true.
|
|
52
|
+
*
|
|
53
|
+
* Every word on screen that comes from the schema, the store or the user is
|
|
54
|
+
* set as text, so a label or a query holding markup is shown as typed.
|
|
55
|
+
*
|
|
56
|
+
* Markup: `.twm-settings` > `.twm-settings__sidebar` (`__search` >
|
|
57
|
+
* `__search-input`, `__nav` > `button.__cat` [`--active`], `__reset-all`)
|
|
58
|
+
* + `.twm-settings__content` (`__header` > `__title`, `__description`,
|
|
59
|
+
* `__reset-category`; `section.__group` > `__group-title` + `.__row`
|
|
60
|
+
* [`--modified`] > `__info` (`__label`, `__marker`, `__description`,
|
|
61
|
+
* `__path`) + `__control` + `__reset`; `__empty`).
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { ComponentBase } from '../base/component_base.js';
|
|
65
|
+
import { ToggleField } from './toggle_field.js';
|
|
66
|
+
import { ColorListField } from './color_list_field.js';
|
|
67
|
+
import { selectBox, shownValue } from './choice_controls.js';
|
|
68
|
+
import { openConfirm } from './modal.js';
|
|
69
|
+
|
|
70
|
+
/** Milliseconds between the last keystroke in a text setting and its write. */
|
|
71
|
+
export const SETTINGS_TEXT_DEBOUNCE_MS = 300;
|
|
72
|
+
|
|
73
|
+
function el(tag, className, text) {
|
|
74
|
+
const n = document.createElement(tag);
|
|
75
|
+
if (className) n.className = className;
|
|
76
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function icon(name) {
|
|
81
|
+
const n = el('span', 'material-symbols-outlined', name);
|
|
82
|
+
n.setAttribute('aria-hidden', 'true');
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function same(a, b) {
|
|
87
|
+
if (a === b) return true;
|
|
88
|
+
if (a == null || b == null) return a == null && b == null;
|
|
89
|
+
if (typeof a !== typeof b || typeof a !== 'object') return false;
|
|
90
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
91
|
+
const ka = Object.keys(a);
|
|
92
|
+
const kb = Object.keys(b);
|
|
93
|
+
return ka.length === kb.length && ka.every((k) => same(a[k], b[k]));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A number written by a number row, always both in range and on the step grid
|
|
98
|
+
* (`min + k * step`, or `k * step` with no `min`): snapped to the nearest grid
|
|
99
|
+
* point, and a point outside [min, max] is moved inward to the nearest grid
|
|
100
|
+
* point inside. `min` is itself a grid point, so the range holds none only
|
|
101
|
+
* when `max < min`; the value is then `min`. With no step it is only clamped.
|
|
102
|
+
*/
|
|
103
|
+
function fitNumber(n, { min, max, step }) {
|
|
104
|
+
const lo = min != null && Number.isFinite(Number(min)) ? Number(min) : null;
|
|
105
|
+
const hi = max != null && Number.isFinite(Number(max)) ? Number(max) : null;
|
|
106
|
+
const s = Number(step);
|
|
107
|
+
if (!(Number.isFinite(s) && s > 0)) {
|
|
108
|
+
let v = n;
|
|
109
|
+
if (hi != null) v = Math.min(hi, v);
|
|
110
|
+
if (lo != null) v = Math.max(lo, v);
|
|
111
|
+
return v;
|
|
112
|
+
}
|
|
113
|
+
const base = lo ?? 0;
|
|
114
|
+
const places = (x) => (String(x).split('.')[1] || '').length;
|
|
115
|
+
const decimals = Math.max(places(s), places(base));
|
|
116
|
+
const at = (k) => Number((base + k * s).toFixed(decimals));
|
|
117
|
+
const EPS = 1e-9;
|
|
118
|
+
let k = Math.round((n - base) / s);
|
|
119
|
+
if (hi != null && at(k) > hi) k = Math.floor((hi - base) / s + EPS);
|
|
120
|
+
if (lo != null && at(k) < lo) k = Math.ceil((lo - base) / s - EPS);
|
|
121
|
+
return at(k);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class SettingsView extends ComponentBase {
|
|
125
|
+
/**
|
|
126
|
+
* @param {object} o
|
|
127
|
+
* @param {object} o.store a settings store (`createSettingsStore()` / `getSettingsStore()`)
|
|
128
|
+
* @param {object} o.eventBus the bus the store emits on
|
|
129
|
+
* @param {Function} [o.confirm] `({title, message, confirmLabel, danger}) -> Promise<boolean>`
|
|
130
|
+
* @param {string} [o.category] the category shown first (default: the first)
|
|
131
|
+
* @param {number} [o.textDebounceMs]
|
|
132
|
+
* @param {string} [o.title='Settings'] the sidebar heading
|
|
133
|
+
*/
|
|
134
|
+
constructor({
|
|
135
|
+
store, eventBus, confirm = openConfirm, category = null,
|
|
136
|
+
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', logger,
|
|
137
|
+
} = {}) {
|
|
138
|
+
super({ eventBus, logger });
|
|
139
|
+
if (!store || typeof store.getSchema !== 'function') {
|
|
140
|
+
throw new TypeError('SettingsView: `store` must be a settings store');
|
|
141
|
+
}
|
|
142
|
+
if (!eventBus || typeof eventBus.on !== 'function') {
|
|
143
|
+
throw new TypeError('SettingsView: `eventBus` must be the bus the store emits on');
|
|
144
|
+
}
|
|
145
|
+
this.store = store;
|
|
146
|
+
this._confirm = confirm;
|
|
147
|
+
this._category = category;
|
|
148
|
+
this._query = '';
|
|
149
|
+
this._debounceMs = textDebounceMs;
|
|
150
|
+
this._title = title;
|
|
151
|
+
|
|
152
|
+
this.root = null;
|
|
153
|
+
this._nav = null;
|
|
154
|
+
this._content = null;
|
|
155
|
+
this._search = null;
|
|
156
|
+
/** path -> { sync(), flush(), dispose() } for the rows on screen. */
|
|
157
|
+
this._rows = new Map();
|
|
158
|
+
/** path -> the bus subscription handle, for every schema path. */
|
|
159
|
+
this._pathSubs = new Map();
|
|
160
|
+
/** Bus subscription handles for the whole-store events. */
|
|
161
|
+
this._subs = [];
|
|
162
|
+
this._disposed = false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build the view into `container` (once). */
|
|
166
|
+
mount(container) {
|
|
167
|
+
if (this.root) return this.root;
|
|
168
|
+
const root = el('div', 'twm-settings');
|
|
169
|
+
const side = el('aside', 'twm-settings__sidebar');
|
|
170
|
+
const head = el('div', 'twm-settings__sidebar-title');
|
|
171
|
+
head.append(icon('settings'), el('span', '', this._title));
|
|
172
|
+
|
|
173
|
+
const search = el('label', 'twm-settings__search');
|
|
174
|
+
const input = el('input', 'twm-settings__search-input');
|
|
175
|
+
input.type = 'search';
|
|
176
|
+
input.placeholder = 'Filter settings…';
|
|
177
|
+
input.autocomplete = 'off';
|
|
178
|
+
input.spellcheck = false;
|
|
179
|
+
input.setAttribute('aria-label', 'Filter settings');
|
|
180
|
+
input.addEventListener('input', () => this.setQuery(input.value));
|
|
181
|
+
search.append(icon('search'), input);
|
|
182
|
+
this._search = input;
|
|
183
|
+
|
|
184
|
+
const nav = el('nav', 'twm-settings__nav');
|
|
185
|
+
nav.setAttribute('aria-label', 'Settings categories');
|
|
186
|
+
nav.addEventListener('click', (e) => {
|
|
187
|
+
const btn = e.target.closest?.('[data-category]');
|
|
188
|
+
if (btn && nav.contains(btn)) this.selectCategory(btn.dataset.category);
|
|
189
|
+
});
|
|
190
|
+
this._nav = nav;
|
|
191
|
+
|
|
192
|
+
const resetAll = el('button', 'twm-btn twm-settings__reset-all');
|
|
193
|
+
resetAll.type = 'button';
|
|
194
|
+
resetAll.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
195
|
+
resetAll.addEventListener('click', () => { this.resetAll(); });
|
|
196
|
+
|
|
197
|
+
side.append(head, search, nav, resetAll);
|
|
198
|
+
|
|
199
|
+
const content = el('div', 'twm-settings__content');
|
|
200
|
+
this._content = content;
|
|
201
|
+
root.append(side, content);
|
|
202
|
+
this.root = root;
|
|
203
|
+
container.appendChild(root);
|
|
204
|
+
this._mounted = true;
|
|
205
|
+
|
|
206
|
+
for (const path of Object.keys(this.store.getSchema())) this.#watch(path);
|
|
207
|
+
this._subs.push(this.eventBus.on('settings:changed', (p) => {
|
|
208
|
+
if (p?.path == null) this.#syncAll();
|
|
209
|
+
}));
|
|
210
|
+
this._subs.push(this.eventBus.on('settings:registered', (p) => this.#onRegistered(p)));
|
|
211
|
+
|
|
212
|
+
this.#paintNav();
|
|
213
|
+
this.#paintContent();
|
|
214
|
+
return root;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The category on screen (its id), or null. */
|
|
218
|
+
getCategory() { return this.#activeCategory()?.id ?? null; }
|
|
219
|
+
|
|
220
|
+
/** The filter text. */
|
|
221
|
+
getQuery() { return this._query; }
|
|
222
|
+
|
|
223
|
+
/** Show one category; clears the filter. */
|
|
224
|
+
selectCategory(id) {
|
|
225
|
+
if (!this.root) return;
|
|
226
|
+
if (!this.store.getCategories().some((c) => c.id === id)) return;
|
|
227
|
+
this._category = id;
|
|
228
|
+
this._query = '';
|
|
229
|
+
if (this._search) this._search.value = '';
|
|
230
|
+
this.#paintNav();
|
|
231
|
+
this.#paintContent();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Search every category; an empty query goes back to the category. */
|
|
235
|
+
setQuery(query) {
|
|
236
|
+
if (!this.root) return;
|
|
237
|
+
const q = String(query ?? '');
|
|
238
|
+
this._query = q;
|
|
239
|
+
if (this._search && this._search.value !== q) this._search.value = q;
|
|
240
|
+
this.#paintNav();
|
|
241
|
+
this.#paintContent();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Reset one category after `confirm`; resolves true when it was reset. */
|
|
245
|
+
async resetCategory(id = this.getCategory()) {
|
|
246
|
+
const cat = this.store.getCategories().find((c) => c.id === id);
|
|
247
|
+
if (!cat) return false;
|
|
248
|
+
const ok = await this._confirm({
|
|
249
|
+
title: 'Reset category',
|
|
250
|
+
message: `Reset every setting in “${cat.label ?? cat.id}” to its default?`,
|
|
251
|
+
confirmLabel: 'Reset',
|
|
252
|
+
danger: true,
|
|
253
|
+
});
|
|
254
|
+
if (!ok || this._disposed) return false;
|
|
255
|
+
this.#flushAll();
|
|
256
|
+
this.store.resetCategory(id);
|
|
257
|
+
this.#syncAll();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Reset every setting after `confirm`; resolves true when they were reset. */
|
|
262
|
+
async resetAll() {
|
|
263
|
+
const ok = await this._confirm({
|
|
264
|
+
title: 'Reset all settings',
|
|
265
|
+
message: 'Reset every setting to its default?',
|
|
266
|
+
confirmLabel: 'Reset all',
|
|
267
|
+
danger: true,
|
|
268
|
+
});
|
|
269
|
+
if (!ok || this._disposed) return false;
|
|
270
|
+
this.#flushAll();
|
|
271
|
+
this.store.resetAll();
|
|
272
|
+
this.#syncAll();
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
dispose() {
|
|
277
|
+
if (this._disposed) return;
|
|
278
|
+
this._disposed = true;
|
|
279
|
+
this.#flushAll();
|
|
280
|
+
this.#dropRows();
|
|
281
|
+
for (const sub of this._pathSubs.values()) sub.dispose();
|
|
282
|
+
this._pathSubs.clear();
|
|
283
|
+
for (const sub of this._subs) sub.dispose();
|
|
284
|
+
this._subs = [];
|
|
285
|
+
this.root?.remove();
|
|
286
|
+
this.root = null;
|
|
287
|
+
this._nav = null;
|
|
288
|
+
this._content = null;
|
|
289
|
+
this._search = null;
|
|
290
|
+
super.dispose();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── store events ─────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
#watch(path) {
|
|
296
|
+
if (this._pathSubs.has(path)) return;
|
|
297
|
+
this._pathSubs.set(path, this.eventBus.on(`settings:${path}:changed`, () => {
|
|
298
|
+
this._rows.get(path)?.sync();
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#onRegistered({ paths = [] } = {}) {
|
|
303
|
+
if (!this.root) return;
|
|
304
|
+
const schema = this.store.getSchema();
|
|
305
|
+
for (const path of Object.keys(schema)) this.#watch(path);
|
|
306
|
+
this.#paintNav();
|
|
307
|
+
const active = this.getCategory();
|
|
308
|
+
const touches = this.#searching()
|
|
309
|
+
|| !this._rows.size
|
|
310
|
+
|| paths.some((p) => schema[p]?.category === active);
|
|
311
|
+
if (touches) {
|
|
312
|
+
this.#paintContent();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// The slice's defaults may have moved values on screen; an edit still
|
|
316
|
+
// being typed is written first, so the sync shows it rather than drop it.
|
|
317
|
+
this.#flushAll();
|
|
318
|
+
this.#syncAll();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** True while a filter is in effect: the trimmed query is not empty. */
|
|
322
|
+
#searching() {
|
|
323
|
+
return this._query.trim() !== '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#syncAll() {
|
|
327
|
+
for (const row of this._rows.values()) row.sync();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#flushAll() {
|
|
331
|
+
for (const row of this._rows.values()) row.flush();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#dropRows() {
|
|
335
|
+
for (const row of this._rows.values()) row.dispose();
|
|
336
|
+
this._rows.clear();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── painting ─────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
#activeCategory() {
|
|
342
|
+
const cats = this.store.getCategories();
|
|
343
|
+
return cats.find((c) => c.id === this._category) ?? cats[0] ?? null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#paintNav() {
|
|
347
|
+
const active = this.#searching() ? null : this.getCategory();
|
|
348
|
+
const buttons = this.store.getCategories().map((cat) => {
|
|
349
|
+
const b = el('button', 'twm-settings__cat');
|
|
350
|
+
b.type = 'button';
|
|
351
|
+
b.dataset.category = cat.id;
|
|
352
|
+
if (cat.id === active) {
|
|
353
|
+
b.classList.add('twm-settings__cat--active');
|
|
354
|
+
b.setAttribute('aria-current', 'page');
|
|
355
|
+
}
|
|
356
|
+
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
357
|
+
if (cat.description) b.title = String(cat.description);
|
|
358
|
+
return b;
|
|
359
|
+
});
|
|
360
|
+
this._nav.replaceChildren(...buttons);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#paintContent() {
|
|
364
|
+
this.#flushAll();
|
|
365
|
+
this.#dropRows();
|
|
366
|
+
const schema = this.store.getSchema();
|
|
367
|
+
const content = this._content;
|
|
368
|
+
content.replaceChildren();
|
|
369
|
+
|
|
370
|
+
if (this.#searching()) {
|
|
371
|
+
const q = this._query.trim().toLowerCase();
|
|
372
|
+
const cats = this.store.getCategories();
|
|
373
|
+
const labelOf = new Map(cats.map((c) => [c.id, c.label ?? c.id]));
|
|
374
|
+
const rank = new Map(cats.map((c, i) => [c.id, i]));
|
|
375
|
+
const matches = Object.entries(schema)
|
|
376
|
+
.filter(([path, def]) => [def.label, def.description, path]
|
|
377
|
+
.some((t) => t != null && String(t).toLowerCase().includes(q)))
|
|
378
|
+
.map(([path, def]) => ({ path, ...def }));
|
|
379
|
+
matches.sort((a, b) => (rank.get(a.category) ?? Infinity) - (rank.get(b.category) ?? Infinity));
|
|
380
|
+
|
|
381
|
+
const header = el('header', 'twm-settings__header');
|
|
382
|
+
const info = el('div', 'twm-settings__header-info');
|
|
383
|
+
const n = matches.length;
|
|
384
|
+
info.append(el('h2', 'twm-settings__title',
|
|
385
|
+
`${n} result${n === 1 ? '' : 's'} for “${this._query.trim()}”`));
|
|
386
|
+
header.append(info);
|
|
387
|
+
content.append(header);
|
|
388
|
+
if (!n) {
|
|
389
|
+
content.append(el('div', 'twm-settings__empty', 'No setting matches the filter.'));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
this.#paintGroups(matches, (def) =>
|
|
393
|
+
`${labelOf.get(def.category) ?? def.category ?? ''} › ${def.group || 'General'}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const cat = this.#activeCategory();
|
|
398
|
+
if (!cat) {
|
|
399
|
+
content.append(el('div', 'twm-settings__empty', 'No settings.'));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// The page drawn is the category: a slice registered later, ordered
|
|
403
|
+
// before it, must not move the sidebar marker or getCategory() off it.
|
|
404
|
+
this._category = cat.id;
|
|
405
|
+
const header = el('header', 'twm-settings__header');
|
|
406
|
+
const info = el('div', 'twm-settings__header-info');
|
|
407
|
+
info.append(el('h2', 'twm-settings__title', cat.label ?? cat.id));
|
|
408
|
+
if (cat.description) info.append(el('p', 'twm-settings__description', cat.description));
|
|
409
|
+
const reset = el('button', 'twm-btn twm-settings__reset-category');
|
|
410
|
+
reset.type = 'button';
|
|
411
|
+
reset.append(icon('restart_alt'), el('span', '', 'Reset category'));
|
|
412
|
+
reset.addEventListener('click', () => { this.resetCategory(cat.id); });
|
|
413
|
+
header.append(info, reset);
|
|
414
|
+
content.append(header);
|
|
415
|
+
|
|
416
|
+
const defs = Object.entries(schema)
|
|
417
|
+
.filter(([, def]) => def.category === cat.id)
|
|
418
|
+
.map(([path, def]) => ({ path, ...def }));
|
|
419
|
+
if (!defs.length) {
|
|
420
|
+
content.append(el('div', 'twm-settings__empty', 'No settings in this category.'));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
this.#paintGroups(defs, (def) => def.group || 'General');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#paintGroups(defs, groupTitle) {
|
|
427
|
+
const groups = new Map();
|
|
428
|
+
for (const def of defs) {
|
|
429
|
+
const key = groupTitle(def);
|
|
430
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
431
|
+
groups.get(key).push(def);
|
|
432
|
+
}
|
|
433
|
+
for (const [title, list] of groups) {
|
|
434
|
+
const section = el('section', 'twm-settings__group');
|
|
435
|
+
section.append(el('h3', 'twm-settings__group-title', title));
|
|
436
|
+
for (const def of list) section.append(this.#row(def));
|
|
437
|
+
this._content.append(section);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── rows ─────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
#row(def) {
|
|
444
|
+
const { path } = def;
|
|
445
|
+
const store = this.store;
|
|
446
|
+
const row = el('div', 'twm-settings__row');
|
|
447
|
+
row.dataset.path = path;
|
|
448
|
+
|
|
449
|
+
const info = el('div', 'twm-settings__info');
|
|
450
|
+
const labelLine = el('div', 'twm-settings__label-line');
|
|
451
|
+
const label = el('span', 'twm-settings__label', def.label ?? path);
|
|
452
|
+
const marker = el('span', 'twm-settings__marker', 'Modified');
|
|
453
|
+
labelLine.append(label, marker);
|
|
454
|
+
info.append(labelLine);
|
|
455
|
+
if (def.description) info.append(el('div', 'twm-settings__description', def.description));
|
|
456
|
+
info.append(el('code', 'twm-settings__path', path));
|
|
457
|
+
|
|
458
|
+
const slot = el('div', 'twm-settings__control');
|
|
459
|
+
const control = this.#control(def, (value) => store.set(path, value));
|
|
460
|
+
slot.append(control.el);
|
|
461
|
+
|
|
462
|
+
const reset = el('button', 'twm-settings__reset');
|
|
463
|
+
reset.type = 'button';
|
|
464
|
+
reset.title = 'Reset to default';
|
|
465
|
+
reset.setAttribute('aria-label', `Reset ${def.label ?? path} to default`);
|
|
466
|
+
reset.append(icon('restart_alt'));
|
|
467
|
+
reset.addEventListener('click', () => {
|
|
468
|
+
control.cancel?.();
|
|
469
|
+
store.resetOne(path);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
row.append(info, slot, reset);
|
|
473
|
+
|
|
474
|
+
const sync = () => {
|
|
475
|
+
const value = store.get(path);
|
|
476
|
+
control.show(value);
|
|
477
|
+
const modified = !same(value, store.getDefault(path));
|
|
478
|
+
row.classList.toggle('twm-settings__row--modified', modified);
|
|
479
|
+
marker.hidden = !modified;
|
|
480
|
+
reset.hidden = !modified;
|
|
481
|
+
};
|
|
482
|
+
sync();
|
|
483
|
+
this._rows.set(path, {
|
|
484
|
+
sync,
|
|
485
|
+
flush: () => control.flush?.(),
|
|
486
|
+
dispose: () => control.dispose?.(),
|
|
487
|
+
});
|
|
488
|
+
return row;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** `{ el, show(value), flush?(), cancel?(), dispose?() }` for one setting. */
|
|
492
|
+
#control(def, write) {
|
|
493
|
+
const name = def.label ?? def.path;
|
|
494
|
+
switch (def.type) {
|
|
495
|
+
case 'boolean': {
|
|
496
|
+
const t = new ToggleField({
|
|
497
|
+
checked: !!this.store.get(def.path), ariaLabel: name, compact: true,
|
|
498
|
+
onChange: (on) => write(on),
|
|
499
|
+
});
|
|
500
|
+
return {
|
|
501
|
+
el: t.render(),
|
|
502
|
+
show: (v) => { if (t.get() !== !!v) t.set(!!v); },
|
|
503
|
+
dispose: () => t.dispose(),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
case 'select': {
|
|
507
|
+
const c = selectBox(def.options || [], this.store.get(def.path), {
|
|
508
|
+
label: name, valueAttr: 'value', className: 'twm-settings__select',
|
|
509
|
+
});
|
|
510
|
+
c.box.addEventListener('change', () => {
|
|
511
|
+
// Redrawn as a fresh mount draws the chosen value, so a stray
|
|
512
|
+
// '(not found)' entry goes once an offered option is picked.
|
|
513
|
+
const v = c.valueOf();
|
|
514
|
+
c.setOptions(def.options || [], v);
|
|
515
|
+
write(v);
|
|
516
|
+
});
|
|
517
|
+
return {
|
|
518
|
+
el: c.box,
|
|
519
|
+
// Redrawn with the new value, as a fresh mount draws it: a
|
|
520
|
+
// value the options lack shows as its '(not found)' entry, and
|
|
521
|
+
// a stray entry left from an earlier value goes.
|
|
522
|
+
show: (v) => { if (!same(c.valueOf(), v)) c.setOptions(def.options || [], v); },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
case 'number': {
|
|
526
|
+
const box = el('input', 'twm-settings__number');
|
|
527
|
+
box.type = 'number';
|
|
528
|
+
box.setAttribute('aria-label', name);
|
|
529
|
+
if (def.min != null) box.min = String(def.min);
|
|
530
|
+
if (def.max != null) box.max = String(def.max);
|
|
531
|
+
if (def.step != null) box.step = String(def.step);
|
|
532
|
+
// Digits typed but not yet committed by 'change'.
|
|
533
|
+
let dirty = false;
|
|
534
|
+
const show = (v) => {
|
|
535
|
+
dirty = false;
|
|
536
|
+
const text = v == null ? '' : String(v);
|
|
537
|
+
if (box.value !== text) box.value = text;
|
|
538
|
+
};
|
|
539
|
+
const commit = () => {
|
|
540
|
+
const current = this.store.get(def.path);
|
|
541
|
+
const n = box.value.trim() === '' ? NaN : Number(box.value);
|
|
542
|
+
if (!Number.isFinite(n)) { show(current); return; }
|
|
543
|
+
const v = fitNumber(n, def);
|
|
544
|
+
show(v);
|
|
545
|
+
if (!same(v, current)) write(v);
|
|
546
|
+
};
|
|
547
|
+
box.addEventListener('input', () => { dirty = true; });
|
|
548
|
+
box.addEventListener('change', commit);
|
|
549
|
+
return {
|
|
550
|
+
el: box,
|
|
551
|
+
show,
|
|
552
|
+
flush: () => { if (dirty) commit(); },
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
case 'text': {
|
|
556
|
+
const box = el('input', 'twm-settings__text');
|
|
557
|
+
box.type = 'text';
|
|
558
|
+
box.setAttribute('aria-label', name);
|
|
559
|
+
if (def.placeholder != null) box.placeholder = String(def.placeholder);
|
|
560
|
+
let timer = null;
|
|
561
|
+
const valueOf = () => (box.value === '' && def.defaultValue === null ? null : box.value);
|
|
562
|
+
const cancel = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
|
|
563
|
+
const commit = () => {
|
|
564
|
+
const v = valueOf();
|
|
565
|
+
if (!same(v, this.store.get(def.path))) write(v);
|
|
566
|
+
};
|
|
567
|
+
const flush = () => {
|
|
568
|
+
if (timer === null) return;
|
|
569
|
+
cancel();
|
|
570
|
+
commit();
|
|
571
|
+
};
|
|
572
|
+
box.addEventListener('input', () => {
|
|
573
|
+
cancel();
|
|
574
|
+
timer = setTimeout(() => { timer = null; commit(); }, this._debounceMs);
|
|
575
|
+
});
|
|
576
|
+
box.addEventListener('change', flush);
|
|
577
|
+
box.addEventListener('keydown', (e) => { if (e.key === 'Enter') flush(); });
|
|
578
|
+
return {
|
|
579
|
+
el: box,
|
|
580
|
+
show: (v) => {
|
|
581
|
+
const text = v == null ? '' : String(v);
|
|
582
|
+
if (box.value === text) return;
|
|
583
|
+
cancel();
|
|
584
|
+
box.value = text;
|
|
585
|
+
},
|
|
586
|
+
flush,
|
|
587
|
+
cancel,
|
|
588
|
+
dispose: cancel,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
case 'colorList': {
|
|
592
|
+
const value = this.store.get(def.path);
|
|
593
|
+
const f = new ColorListField({
|
|
594
|
+
value: Array.isArray(value) ? value : [],
|
|
595
|
+
onChange: (colors, { commit }) => { if (commit && colors) write(colors); },
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
el: f.render(),
|
|
599
|
+
show: (v) => {
|
|
600
|
+
const list = Array.isArray(v) ? v : [];
|
|
601
|
+
if (!same(f.getValue(), list)) f.setValue(list);
|
|
602
|
+
},
|
|
603
|
+
dispose: () => f.dispose(),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
default: {
|
|
607
|
+
const out = el('span', 'twm-settings__readonly');
|
|
608
|
+
return {
|
|
609
|
+
el: out,
|
|
610
|
+
show: (v) => { out.textContent = shownValue(v); },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A content factory for the shell: `(hostEl, props, ctx) -> { title, destroy }`.
|
|
619
|
+
* `props.category` picks the category shown first.
|
|
620
|
+
*
|
|
621
|
+
* @param {object} opts as `new SettingsView(opts)`
|
|
622
|
+
* @returns {Function}
|
|
623
|
+
*/
|
|
624
|
+
export function settingsViewContent(opts = {}) {
|
|
625
|
+
return (hostEl, props = {}) => {
|
|
626
|
+
const view = new SettingsView({ ...opts, category: props?.category ?? opts.category ?? null });
|
|
627
|
+
view.mount(hostEl);
|
|
628
|
+
return {
|
|
629
|
+
title: opts.title ?? 'Settings',
|
|
630
|
+
view,
|
|
631
|
+
destroy: () => view.dispose(),
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
}
|