flexdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SplitPaneContainer — horizontal split with a draggable divider.
|
|
3
|
+
*
|
|
4
|
+
* Manages exactly 1 or 2 child panes. When only one pane exists, the divider
|
|
5
|
+
* is hidden and the single pane fills the container. When split, both panes
|
|
6
|
+
* share space according to a ratio (default 50/50), adjustable via drag.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const split = new SplitPaneContainer();
|
|
10
|
+
* split.mount(parentEl);
|
|
11
|
+
* split.setPanes(leftEl); // single pane
|
|
12
|
+
* split.setPanes(leftEl, rightEl); // split view
|
|
13
|
+
* split.setRatio(0.6); // 60/40 split
|
|
14
|
+
* split.unsplit(); // collapse to single pane
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class SplitPaneContainer {
|
|
18
|
+
/** @type {HTMLElement} */
|
|
19
|
+
#container = null;
|
|
20
|
+
|
|
21
|
+
/** @type {HTMLElement} */
|
|
22
|
+
#pane1 = null;
|
|
23
|
+
|
|
24
|
+
/** @type {HTMLElement} */
|
|
25
|
+
#pane2 = null;
|
|
26
|
+
|
|
27
|
+
/** @type {HTMLElement} */
|
|
28
|
+
#divider = null;
|
|
29
|
+
|
|
30
|
+
/** @type {number} 0..1, fraction of width for pane 1 */
|
|
31
|
+
#ratio = 0.5;
|
|
32
|
+
|
|
33
|
+
/** @type {boolean} */
|
|
34
|
+
#isSplit = false;
|
|
35
|
+
|
|
36
|
+
/** @type {Function|null} */
|
|
37
|
+
#onRatioChanged = null;
|
|
38
|
+
|
|
39
|
+
// Drag state
|
|
40
|
+
#dragging = false;
|
|
41
|
+
#boundOnMouseMove = null;
|
|
42
|
+
#boundOnMouseUp = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{ onRatioChanged?: (ratio: number) => void }} opts
|
|
46
|
+
*/
|
|
47
|
+
constructor(opts = {}) {
|
|
48
|
+
this.#onRatioChanged = opts.onRatioChanged ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get isSplit() { return this.#isSplit; }
|
|
52
|
+
get ratio() { return this.#ratio; }
|
|
53
|
+
|
|
54
|
+
mount(container) {
|
|
55
|
+
this.#container = container;
|
|
56
|
+
this.#container.classList.add('split-pane-container');
|
|
57
|
+
this.#container.innerHTML = '';
|
|
58
|
+
|
|
59
|
+
// Divider
|
|
60
|
+
this.#divider = document.createElement('div');
|
|
61
|
+
this.#divider.className = 'split-pane-divider';
|
|
62
|
+
this.#divider.addEventListener('mousedown', (e) => this.#onDividerMouseDown(e));
|
|
63
|
+
|
|
64
|
+
this.#applyLayout();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Set pane elements. Pass one element for single view, two for split.
|
|
69
|
+
* @param {HTMLElement} pane1
|
|
70
|
+
* @param {HTMLElement} [pane2]
|
|
71
|
+
*/
|
|
72
|
+
setPanes(pane1, pane2) {
|
|
73
|
+
this.#pane1 = pane1;
|
|
74
|
+
this.#pane2 = pane2 ?? null;
|
|
75
|
+
this.#isSplit = !!pane2;
|
|
76
|
+
this.#applyLayout();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set the split ratio (fraction for left pane).
|
|
81
|
+
* @param {number} ratio — 0.2..0.8
|
|
82
|
+
*/
|
|
83
|
+
setRatio(ratio) {
|
|
84
|
+
this.#ratio = Math.max(0.2, Math.min(0.8, ratio));
|
|
85
|
+
if (this.#isSplit) this.#applySizes();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collapse to single pane (left). Returns the removed right pane element.
|
|
90
|
+
* @returns {HTMLElement|null}
|
|
91
|
+
*/
|
|
92
|
+
unsplit() {
|
|
93
|
+
if (!this.#isSplit) return null;
|
|
94
|
+
const removed = this.#pane2;
|
|
95
|
+
this.#pane2 = null;
|
|
96
|
+
this.#isSplit = false;
|
|
97
|
+
this.#applyLayout();
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
dispose() {
|
|
102
|
+
this.#stopDrag();
|
|
103
|
+
this.#container = null;
|
|
104
|
+
this.#pane1 = null;
|
|
105
|
+
this.#pane2 = null;
|
|
106
|
+
this.#divider = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Layout ──────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
#applyLayout() {
|
|
112
|
+
if (!this.#container) return;
|
|
113
|
+
this.#container.innerHTML = '';
|
|
114
|
+
|
|
115
|
+
if (!this.#pane1) return;
|
|
116
|
+
|
|
117
|
+
if (!this.#isSplit) {
|
|
118
|
+
// Single pane: fills container
|
|
119
|
+
this.#pane1.className = 'split-pane';
|
|
120
|
+
this.#pane1.style.flex = '1 1 0';
|
|
121
|
+
this.#pane1.style.minWidth = '0';
|
|
122
|
+
this.#container.appendChild(this.#pane1);
|
|
123
|
+
this.#container.classList.remove('split-pane-container--split');
|
|
124
|
+
} else {
|
|
125
|
+
// Split view
|
|
126
|
+
this.#pane1.className = 'split-pane';
|
|
127
|
+
this.#pane2.className = 'split-pane';
|
|
128
|
+
this.#pane1.style.minWidth = '0';
|
|
129
|
+
this.#pane2.style.minWidth = '0';
|
|
130
|
+
|
|
131
|
+
this.#container.appendChild(this.#pane1);
|
|
132
|
+
this.#container.appendChild(this.#divider);
|
|
133
|
+
this.#container.appendChild(this.#pane2);
|
|
134
|
+
this.#container.classList.add('split-pane-container--split');
|
|
135
|
+
|
|
136
|
+
this.#applySizes();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#applySizes() {
|
|
141
|
+
if (!this.#pane1 || !this.#pane2) return;
|
|
142
|
+
// Use flex-grow proportional to ratio
|
|
143
|
+
const r = this.#ratio;
|
|
144
|
+
this.#pane1.style.flex = `${r} 1 0`;
|
|
145
|
+
this.#pane2.style.flex = `${1 - r} 1 0`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Divider drag ────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
#onDividerMouseDown(e) {
|
|
151
|
+
if (e.button !== 0) return;
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
this.#dragging = true;
|
|
154
|
+
this.#divider.classList.add('split-pane-divider--active');
|
|
155
|
+
document.body.style.cursor = 'col-resize';
|
|
156
|
+
|
|
157
|
+
// Prevent text selection and iframes capturing mouse
|
|
158
|
+
document.body.style.userSelect = 'none';
|
|
159
|
+
this.#container.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
|
|
160
|
+
|
|
161
|
+
this.#boundOnMouseMove = (e) => this.#onMouseMove(e);
|
|
162
|
+
this.#boundOnMouseUp = () => this.#onMouseUp();
|
|
163
|
+
document.addEventListener('mousemove', this.#boundOnMouseMove);
|
|
164
|
+
document.addEventListener('mouseup', this.#boundOnMouseUp);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#onMouseMove(e) {
|
|
168
|
+
if (!this.#dragging || !this.#container) return;
|
|
169
|
+
const rect = this.#container.getBoundingClientRect();
|
|
170
|
+
const dividerWidth = this.#divider.offsetWidth;
|
|
171
|
+
const usable = rect.width - dividerWidth;
|
|
172
|
+
if (usable <= 0) return;
|
|
173
|
+
|
|
174
|
+
const x = e.clientX - rect.left - dividerWidth / 2;
|
|
175
|
+
const ratio = Math.max(0.2, Math.min(0.8, x / usable));
|
|
176
|
+
this.#ratio = ratio;
|
|
177
|
+
this.#applySizes();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#onMouseUp() {
|
|
181
|
+
this.#stopDrag();
|
|
182
|
+
this.#onRatioChanged?.(this.#ratio);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#stopDrag() {
|
|
186
|
+
if (!this.#dragging) return;
|
|
187
|
+
this.#dragging = false;
|
|
188
|
+
this.#divider?.classList.remove('split-pane-divider--active');
|
|
189
|
+
document.body.style.cursor = '';
|
|
190
|
+
document.body.style.userSelect = '';
|
|
191
|
+
this.#container?.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
|
|
192
|
+
document.removeEventListener('mousemove', this.#boundOnMouseMove);
|
|
193
|
+
document.removeEventListener('mouseup', this.#boundOnMouseUp);
|
|
194
|
+
this.#boundOnMouseMove = null;
|
|
195
|
+
this.#boundOnMouseUp = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HelpModal - Modal dialog for displaying help content
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Markdown rendering with syntax highlighting
|
|
6
|
+
* - Table of contents navigation
|
|
7
|
+
* - Topic search
|
|
8
|
+
* - Category browsing
|
|
9
|
+
* - Keyboard navigation (Escape to close)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { helpProvider, helpCategories, helpCopy } from './help_registry.js';
|
|
13
|
+
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
// HelpModal Class
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
let _activeModal = null;
|
|
19
|
+
|
|
20
|
+
export class HelpModal {
|
|
21
|
+
/** The application's help, resolved at use time. */
|
|
22
|
+
get _service() { return helpProvider(); }
|
|
23
|
+
|
|
24
|
+
get _categories() { return helpCategories(); }
|
|
25
|
+
|
|
26
|
+
get _copy() { return helpCopy(); }
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
// Read LAZILY, not here. A HelpModal can be constructed at boot (the
|
|
30
|
+
// command palette holds one) before the application has registered its
|
|
31
|
+
// help, and a constructor read would freeze the empty default forever.
|
|
32
|
+
this._overlay = null;
|
|
33
|
+
this._modal = null;
|
|
34
|
+
this._currentTopic = null;
|
|
35
|
+
this._searchQuery = '';
|
|
36
|
+
this._boundKeyHandler = this._handleKeyDown.bind(this);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Open the help modal.
|
|
41
|
+
* @param {string} [topicId] - Initial topic to display
|
|
42
|
+
*/
|
|
43
|
+
async open(topicId = null) {
|
|
44
|
+
// Close any existing modal
|
|
45
|
+
if (_activeModal && _activeModal !== this) {
|
|
46
|
+
_activeModal.close();
|
|
47
|
+
}
|
|
48
|
+
_activeModal = this;
|
|
49
|
+
|
|
50
|
+
this._createModal();
|
|
51
|
+
document.body.appendChild(this._overlay);
|
|
52
|
+
|
|
53
|
+
// Add keyboard handler
|
|
54
|
+
document.addEventListener('keydown', this._boundKeyHandler);
|
|
55
|
+
|
|
56
|
+
// Focus search input
|
|
57
|
+
const searchInput = this._modal.querySelector('.help-search-input');
|
|
58
|
+
if (searchInput) {
|
|
59
|
+
setTimeout(() => searchInput.focus(), 100);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Load initial topic or show index
|
|
63
|
+
if (topicId) {
|
|
64
|
+
await this._loadTopic(topicId);
|
|
65
|
+
} else {
|
|
66
|
+
this._showIndex();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Close the help modal.
|
|
72
|
+
*/
|
|
73
|
+
close() {
|
|
74
|
+
if (this._overlay) {
|
|
75
|
+
document.removeEventListener('keydown', this._boundKeyHandler);
|
|
76
|
+
this._overlay.remove();
|
|
77
|
+
this._overlay = null;
|
|
78
|
+
this._modal = null;
|
|
79
|
+
}
|
|
80
|
+
if (_activeModal === this) {
|
|
81
|
+
_activeModal = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create the modal DOM structure.
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
_createModal() {
|
|
90
|
+
// Overlay
|
|
91
|
+
this._overlay = document.createElement('div');
|
|
92
|
+
this._overlay.className = 'help-modal-overlay';
|
|
93
|
+
this._overlay.addEventListener('click', (e) => {
|
|
94
|
+
if (e.target === this._overlay) this.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Modal container
|
|
98
|
+
this._modal = document.createElement('div');
|
|
99
|
+
this._modal.className = 'help-modal';
|
|
100
|
+
this._modal.innerHTML = `
|
|
101
|
+
<div class="help-modal__header">
|
|
102
|
+
<div class="help-modal__title">
|
|
103
|
+
<span class="material-symbols-outlined">help</span>
|
|
104
|
+
<span>Help</span>
|
|
105
|
+
</div>
|
|
106
|
+
<button class="help-modal__close" title="Close (Esc)">
|
|
107
|
+
<span class="material-symbols-outlined">close</span>
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="help-modal__search">
|
|
111
|
+
<span class="material-symbols-outlined">search</span>
|
|
112
|
+
<input type="text" class="help-search-input" placeholder="Search help topics..." autocomplete="off">
|
|
113
|
+
</div>
|
|
114
|
+
<div class="help-modal__body">
|
|
115
|
+
<nav class="help-modal__sidebar">
|
|
116
|
+
<div class="help-sidebar__categories"></div>
|
|
117
|
+
</nav>
|
|
118
|
+
<main class="help-modal__content">
|
|
119
|
+
<div class="help-content__loading">Loading...</div>
|
|
120
|
+
</main>
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
// Event handlers
|
|
125
|
+
const closeBtn = this._modal.querySelector('.help-modal__close');
|
|
126
|
+
closeBtn.addEventListener('click', () => this.close());
|
|
127
|
+
|
|
128
|
+
const searchInput = this._modal.querySelector('.help-search-input');
|
|
129
|
+
searchInput.addEventListener('input', (e) => this._handleSearch(e.target.value));
|
|
130
|
+
searchInput.addEventListener('keydown', (e) => {
|
|
131
|
+
if (e.key === 'Escape') {
|
|
132
|
+
if (e.target.value) {
|
|
133
|
+
e.target.value = '';
|
|
134
|
+
this._handleSearch('');
|
|
135
|
+
e.stopPropagation();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Build sidebar
|
|
141
|
+
this._buildSidebar();
|
|
142
|
+
|
|
143
|
+
this._overlay.appendChild(this._modal);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the sidebar navigation.
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_buildSidebar() {
|
|
151
|
+
const container = this._modal.querySelector('.help-sidebar__categories');
|
|
152
|
+
const categories = this._service.getCategories();
|
|
153
|
+
|
|
154
|
+
// Sort categories by order
|
|
155
|
+
const categoryOrder = Object.keys(this._categories);
|
|
156
|
+
categories.sort((a, b) => {
|
|
157
|
+
const orderA = categoryOrder.indexOf(a);
|
|
158
|
+
const orderB = categoryOrder.indexOf(b);
|
|
159
|
+
return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
container.innerHTML = categories.map(category => {
|
|
163
|
+
const topics = this._service.getTopicsByCategory(category);
|
|
164
|
+
const label = this._categories[category]?.label || category;
|
|
165
|
+
const icon = this._categories[category]?.icon || 'folder';
|
|
166
|
+
|
|
167
|
+
return `
|
|
168
|
+
<div class="help-category" data-category="${category}">
|
|
169
|
+
<div class="help-category__header">
|
|
170
|
+
<span class="material-symbols-outlined">${icon}</span>
|
|
171
|
+
<span>${label}</span>
|
|
172
|
+
</div>
|
|
173
|
+
<ul class="help-category__topics">
|
|
174
|
+
${topics.map(topic => `
|
|
175
|
+
<li class="help-topic-item" data-topic="${topic.id}">
|
|
176
|
+
${topic.title}
|
|
177
|
+
</li>
|
|
178
|
+
`).join('')}
|
|
179
|
+
</ul>
|
|
180
|
+
</div>
|
|
181
|
+
`;
|
|
182
|
+
}).join('');
|
|
183
|
+
|
|
184
|
+
// Add click handlers
|
|
185
|
+
container.querySelectorAll('.help-topic-item').forEach(item => {
|
|
186
|
+
item.addEventListener('click', () => {
|
|
187
|
+
this._loadTopic(item.dataset.topic);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
container.querySelectorAll('.help-category__header').forEach(header => {
|
|
192
|
+
header.addEventListener('click', () => {
|
|
193
|
+
header.parentElement.classList.toggle('collapsed');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Show the help index page.
|
|
200
|
+
* @private
|
|
201
|
+
*/
|
|
202
|
+
_showIndex() {
|
|
203
|
+
const content = this._modal.querySelector('.help-modal__content');
|
|
204
|
+
const categories = this._service.getCategories();
|
|
205
|
+
|
|
206
|
+
const categoryOrder = Object.keys(this._categories);
|
|
207
|
+
categories.sort((a, b) => {
|
|
208
|
+
const orderA = categoryOrder.indexOf(a);
|
|
209
|
+
const orderB = categoryOrder.indexOf(b);
|
|
210
|
+
return (orderA === -1 ? 99 : orderA) - (orderB === -1 ? 99 : orderB);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
content.innerHTML = `
|
|
214
|
+
<div class="help-index">
|
|
215
|
+
<h1>${this._copy.title}</h1>
|
|
216
|
+
<p>${this._copy.welcome}</p>
|
|
217
|
+
|
|
218
|
+
<div class="help-index__grid">
|
|
219
|
+
${categories.map(category => {
|
|
220
|
+
const topics = this._service.getTopicsByCategory(category);
|
|
221
|
+
const label = this._categories[category]?.label || category;
|
|
222
|
+
const icon = this._categories[category]?.icon || 'folder';
|
|
223
|
+
|
|
224
|
+
return `
|
|
225
|
+
<div class="help-index__category" data-category="${category}">
|
|
226
|
+
<div class="help-index__category-header">
|
|
227
|
+
<span class="material-symbols-outlined">${icon}</span>
|
|
228
|
+
<span>${label}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<ul class="help-index__topics">
|
|
231
|
+
${topics.slice(0, 3).map(topic => `
|
|
232
|
+
<li class="help-index__topic" data-topic="${topic.id}">
|
|
233
|
+
${topic.title}
|
|
234
|
+
</li>
|
|
235
|
+
`).join('')}
|
|
236
|
+
${topics.length > 3 ? `<li class="help-index__more">+${topics.length - 3} more</li>` : ''}
|
|
237
|
+
</ul>
|
|
238
|
+
</div>
|
|
239
|
+
`;
|
|
240
|
+
}).join('')}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
// Add click handlers
|
|
246
|
+
content.querySelectorAll('.help-index__topic').forEach(item => {
|
|
247
|
+
item.addEventListener('click', () => {
|
|
248
|
+
this._loadTopic(item.dataset.topic);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Load and display a topic.
|
|
255
|
+
* @param {string} topicId - Topic ID
|
|
256
|
+
* @private
|
|
257
|
+
*/
|
|
258
|
+
async _loadTopic(topicId) {
|
|
259
|
+
const content = this._modal.querySelector('.help-modal__content');
|
|
260
|
+
content.innerHTML = '<div class="help-content__loading">Loading...</div>';
|
|
261
|
+
|
|
262
|
+
// Update sidebar selection
|
|
263
|
+
this._modal.querySelectorAll('.help-topic-item').forEach(item => {
|
|
264
|
+
item.classList.toggle('active', item.dataset.topic === topicId);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const topic = await this._service.loadTopic(topicId);
|
|
269
|
+
this._currentTopic = topicId;
|
|
270
|
+
|
|
271
|
+
content.innerHTML = `
|
|
272
|
+
<div class="help-content__article">
|
|
273
|
+
<div class="help-content__breadcrumb">
|
|
274
|
+
<a href="#" class="help-breadcrumb__home" title="Help Index">
|
|
275
|
+
<span class="material-symbols-outlined">home</span>
|
|
276
|
+
</a>
|
|
277
|
+
<span class="help-breadcrumb__separator">/</span>
|
|
278
|
+
<span class="help-breadcrumb__title">${topic.title}</span>
|
|
279
|
+
</div>
|
|
280
|
+
<article class="help-article">
|
|
281
|
+
${topic.html}
|
|
282
|
+
</article>
|
|
283
|
+
</div>
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
// Add breadcrumb handler
|
|
287
|
+
content.querySelector('.help-breadcrumb__home').addEventListener('click', (e) => {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
this._showIndex();
|
|
290
|
+
});
|
|
291
|
+
} catch (error) {
|
|
292
|
+
content.innerHTML = `
|
|
293
|
+
<div class="help-content__error">
|
|
294
|
+
<span class="material-symbols-outlined">error</span>
|
|
295
|
+
<p>Failed to load help topic.</p>
|
|
296
|
+
</div>
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Handle search input.
|
|
303
|
+
* @param {string} query - Search query
|
|
304
|
+
* @private
|
|
305
|
+
*/
|
|
306
|
+
_handleSearch(query) {
|
|
307
|
+
this._searchQuery = query;
|
|
308
|
+
const content = this._modal.querySelector('.help-modal__content');
|
|
309
|
+
|
|
310
|
+
if (!query.trim()) {
|
|
311
|
+
this._showIndex();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const results = this._service.searchTopics(query);
|
|
316
|
+
|
|
317
|
+
if (results.length === 0) {
|
|
318
|
+
content.innerHTML = `
|
|
319
|
+
<div class="help-search-results">
|
|
320
|
+
<h2>Search Results</h2>
|
|
321
|
+
<p class="help-search-empty">No results found for "${query}"</p>
|
|
322
|
+
</div>
|
|
323
|
+
`;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
content.innerHTML = `
|
|
328
|
+
<div class="help-search-results">
|
|
329
|
+
<h2>Search Results</h2>
|
|
330
|
+
<p class="help-search-count">${results.length} result${results.length !== 1 ? 's' : ''} for "${query}"</p>
|
|
331
|
+
<ul class="help-search-list">
|
|
332
|
+
${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>
|
|
336
|
+
</li>
|
|
337
|
+
`).join('')}
|
|
338
|
+
</ul>
|
|
339
|
+
</div>
|
|
340
|
+
`;
|
|
341
|
+
|
|
342
|
+
// Add click handlers
|
|
343
|
+
content.querySelectorAll('.help-search-item').forEach(item => {
|
|
344
|
+
item.addEventListener('click', () => {
|
|
345
|
+
this._loadTopic(item.dataset.topic);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Handle keyboard events.
|
|
352
|
+
* @param {KeyboardEvent} event
|
|
353
|
+
* @private
|
|
354
|
+
*/
|
|
355
|
+
_handleKeyDown(event) {
|
|
356
|
+
if (event.key === 'Escape') {
|
|
357
|
+
this.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Static method to open help modal.
|
|
363
|
+
* @param {string} [topicId] - Initial topic
|
|
364
|
+
* @returns {HelpModal}
|
|
365
|
+
*/
|
|
366
|
+
static open(topicId = null) {
|
|
367
|
+
const modal = new HelpModal();
|
|
368
|
+
modal.open(topicId);
|
|
369
|
+
return modal;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Get the currently active modal.
|
|
374
|
+
* @returns {HelpModal|null}
|
|
375
|
+
*/
|
|
376
|
+
static getActive() {
|
|
377
|
+
return _activeModal;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
382
|
+
// Global Help Shortcut
|
|
383
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Open help modal with the `?` key. (F1 is reserved for top-nav Home.)
|
|
387
|
+
*/
|
|
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
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Initialize global shortcut
|
|
405
|
+
if (typeof document !== 'undefined') {
|
|
406
|
+
setupGlobalHelpShortcut();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export default HelpModal;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the help modal gets its help.
|
|
3
|
+
*
|
|
4
|
+
* HelpModal used to `import { getHelpService, HELP_TOPICS } from './help_service.js'` —
|
|
5
|
+
* and help_service.js is 249 domain words of EcoAgent prose about sectors,
|
|
6
|
+
* currencies, agents and markets. That import was the last framework -> domain
|
|
7
|
+
* edge in the core: a generic modal reaching into one application's manual.
|
|
8
|
+
*
|
|
9
|
+
* The modal now asks this registry, and the application fills it. If nobody
|
|
10
|
+
* fills it, the modal opens and says there is no help — which is the correct
|
|
11
|
+
* behaviour for a library that ships no documentation about your app.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {Object} HelpProvider
|
|
14
|
+
* @property {() => string[]} getCategories
|
|
15
|
+
* @property {(category: string) => Array<{id: string, title: string}>} getTopicsByCategory
|
|
16
|
+
* @property {(id: string) => Promise<Object|null>} loadTopic
|
|
17
|
+
* @property {(query: string) => Array<Object>} searchTopics
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object.<string, {label: string, icon: string}>} HelpCategories
|
|
20
|
+
* Keyed by category id; ITERATION ORDER IS DISPLAY ORDER.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {{title: string, welcome: string}} HelpCopy
|
|
23
|
+
* The landing heading. The modal used to hardcode "EcoAgent Help" and
|
|
24
|
+
* "Welcome to EcoAgent!".
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A provider with nothing in it. Not an error — just an app with no help. */
|
|
28
|
+
const EMPTY_PROVIDER = Object.freeze({
|
|
29
|
+
getCategories: () => [],
|
|
30
|
+
getTopicsByCategory: () => [],
|
|
31
|
+
loadTopic: async () => null,
|
|
32
|
+
searchTopics: () => [],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const DEFAULT_COPY = Object.freeze({
|
|
36
|
+
title: 'Help',
|
|
37
|
+
welcome: 'Select a topic from the sidebar or browse the categories below.',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let _provider = EMPTY_PROVIDER;
|
|
41
|
+
let _categories = Object.freeze({});
|
|
42
|
+
let _copy = DEFAULT_COPY;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {HelpProvider} provider
|
|
46
|
+
* @param {HelpCategories} [categories] Display labels + icons, in display order.
|
|
47
|
+
* @param {Partial<HelpCopy>} [copy] Landing heading and welcome line.
|
|
48
|
+
*/
|
|
49
|
+
export function setHelpProvider(provider, categories = {}, copy = {}) {
|
|
50
|
+
_provider = provider ?? EMPTY_PROVIDER;
|
|
51
|
+
_categories = Object.freeze({ ...categories });
|
|
52
|
+
_copy = Object.freeze({ ...DEFAULT_COPY, ...copy });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function helpProvider() {
|
|
56
|
+
return _provider;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function helpCategories() {
|
|
60
|
+
return _categories;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function helpCopy() {
|
|
64
|
+
return _copy;
|
|
65
|
+
}
|