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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.js — `createShell()`, the library's composition entry point.
|
|
3
|
+
*
|
|
4
|
+
* Requires: root, taxonomy, entities, content
|
|
5
|
+
* Hosts: chrome (ELEMENTS, never selectors), host port, bus, stores
|
|
6
|
+
* Owns: the content registry, the WM, the palette, the keymap, the
|
|
7
|
+
* chrome painters, the tile context menus.
|
|
8
|
+
* Knows: nothing about the embedder. No `document.body`, no selector it
|
|
9
|
+
* did not author, no bus-event string, no content kind.
|
|
10
|
+
*
|
|
11
|
+
* ── The seam ──────────────────────────────────────────────────────────
|
|
12
|
+
* The embedder does everything up to "hand the framework a root element and
|
|
13
|
+
* a content map", and everything after `createShell()` resolves:
|
|
14
|
+
*
|
|
15
|
+
* embedder: gate (is there anything to show?) -> install its own widgets
|
|
16
|
+
* -> build/clear the root element -> build the content map
|
|
17
|
+
* library: registry -> WM -> palette -> keymap -> chrome -> wm.load()
|
|
18
|
+
* embedder: publish the shell, install post-mount extras
|
|
19
|
+
*
|
|
20
|
+
* That order is not stylistic. The widgets decorate DOM the embedder then
|
|
21
|
+
* DELETES to make room for the root element, so `root` cannot exist before
|
|
22
|
+
* the widgets have run — which is exactly why `createShell` does not try to
|
|
23
|
+
* own a `prepare`/`widgets` lifecycle. It takes a root element that already
|
|
24
|
+
* exists and mounts into it.
|
|
25
|
+
*
|
|
26
|
+
* ── Content is a constructor argument ─────────────────────────────────
|
|
27
|
+
* `content` is REQUIRED, validated, and frozen into a per-shell registry
|
|
28
|
+
* BEFORE the WM is built. There is no module-level `register()` to call
|
|
29
|
+
* late. That matters: a leaf mounted against a missing factory renders a
|
|
30
|
+
* placeholder, and the renderer caches tile DOM per (kind, props) — a late
|
|
31
|
+
* registration invalidates nothing, so a whole restored session stays wrong.
|
|
32
|
+
* Making registration impossible after the first mount is the fix.
|
|
33
|
+
*
|
|
34
|
+
* ── Chrome is elements, not selectors ─────────────────────────────────
|
|
35
|
+
* A selector parameter would be the coupling wearing a hat: the framework
|
|
36
|
+
* would still have to know that a "top-nav host" is a thing you find by CSS
|
|
37
|
+
* in someone else's markup. Handing it a node is the smallest honest
|
|
38
|
+
* abstraction — and an embedder with no chrome passes `chrome: {}` and gets
|
|
39
|
+
* a working WM with none.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { WindowManager } from './wm.js';
|
|
43
|
+
import { createContentRegistry } from './content_registry.js';
|
|
44
|
+
import { createCommandPalette } from './command_palette.js';
|
|
45
|
+
import { installKeymap } from './keymap.js';
|
|
46
|
+
import { openTileTabMenu } from './tile_tab_menu.js';
|
|
47
|
+
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
48
|
+
import { openForm } from '../ui/components/modal.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} cfg
|
|
52
|
+
* @param {Element} cfg.root the element the WM mounts into
|
|
53
|
+
* @param {object} cfg.taxonomy createTaxonomy(...) — required
|
|
54
|
+
* @param {object} cfg.entities createEntityCatalog(...) — required
|
|
55
|
+
* @param {object} cfg.content { [kind]: (hostEl, props, ctx) => {...} }
|
|
56
|
+
* @param {object} [cfg.host] the host port (createHost)
|
|
57
|
+
* @param {object} [cfg.api] an opaque back-end handle, threaded into
|
|
58
|
+
* ctx.api for content factories. The library
|
|
59
|
+
* never calls a method on it; it only passes
|
|
60
|
+
* it through (the palette + tile tab menu
|
|
61
|
+
* hand it to the entity catalog's fetchers).
|
|
62
|
+
* @param {object} [cfg.eventBus]
|
|
63
|
+
* @param {object} [cfg.logger]
|
|
64
|
+
* @param {object} [cfg.tableStore] threaded into ctx for table persistence
|
|
65
|
+
* @param {object} [cfg.events] bus-event NAMES the WM listens for
|
|
66
|
+
* @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
|
|
67
|
+
* @param {object} [cfg.palette] { placeholder }
|
|
68
|
+
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
69
|
+
* panelToggles?: { left?, right?, bottom? } }
|
|
70
|
+
* @returns {Promise<object>} the frozen shell
|
|
71
|
+
*/
|
|
72
|
+
export async function createShell({
|
|
73
|
+
root,
|
|
74
|
+
taxonomy,
|
|
75
|
+
entities,
|
|
76
|
+
content,
|
|
77
|
+
host = null,
|
|
78
|
+
api = null,
|
|
79
|
+
eventBus = null,
|
|
80
|
+
logger = null,
|
|
81
|
+
tableStore = null,
|
|
82
|
+
events = {},
|
|
83
|
+
rootCrumb = null,
|
|
84
|
+
palette: paletteCfg = {},
|
|
85
|
+
chrome = {},
|
|
86
|
+
} = {}) {
|
|
87
|
+
// A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
|
|
88
|
+
// createHost / createTaxonomy.
|
|
89
|
+
if (!root || typeof root.appendChild !== 'function') {
|
|
90
|
+
throw new TypeError('createShell: `root` must be an element');
|
|
91
|
+
}
|
|
92
|
+
if (!taxonomy) throw new Error('createShell: a taxonomy is required');
|
|
93
|
+
if (!entities) throw new Error('createShell: an entity catalog is required');
|
|
94
|
+
|
|
95
|
+
const log = logger ?? { info(){}, warn(){}, error(){}, debug(){} };
|
|
96
|
+
const registry = createContentRegistry(content); // throws on a non-function
|
|
97
|
+
|
|
98
|
+
let wm = null;
|
|
99
|
+
let topNavEl = null;
|
|
100
|
+
let desktopsEl = null;
|
|
101
|
+
let toggles = null;
|
|
102
|
+
|
|
103
|
+
const syncChrome = () => {
|
|
104
|
+
if (!wm) return;
|
|
105
|
+
syncPanelToggles(toggles, wm);
|
|
106
|
+
syncDesktopBar(desktopsEl, wm);
|
|
107
|
+
syncTopNav(topNavEl, wm);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
wm = new WindowManager({
|
|
111
|
+
rootEl: root,
|
|
112
|
+
content: registry,
|
|
113
|
+
api,
|
|
114
|
+
eventBus,
|
|
115
|
+
host,
|
|
116
|
+
taxonomy,
|
|
117
|
+
events,
|
|
118
|
+
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
119
|
+
// spreads it into every content factory, which is how the breadcrumb
|
|
120
|
+
// gets `taxonomy` + `rootCrumb` without a content factory knowing they
|
|
121
|
+
// exist. The two `onTile*Menu` keys are read with OPTIONAL CHAINING in
|
|
122
|
+
// tile_renderer — drop one and right-click silently does nothing.
|
|
123
|
+
ctx: {
|
|
124
|
+
api, eventBus, host, tableStore,
|
|
125
|
+
taxonomy, entities, rootCrumb, events,
|
|
126
|
+
onTileContextMenu: (leafId, x, y) => _tileContextMenu(wm, leafId, x, y),
|
|
127
|
+
onTileTabMenu: (leafId, x, y) => _tileTabMenu(wm, leafId, x, y),
|
|
128
|
+
},
|
|
129
|
+
onChange: () => syncChrome(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const palette = createCommandPalette({
|
|
133
|
+
wm, api, taxonomy, catalog: entities, ...paletteCfg,
|
|
134
|
+
});
|
|
135
|
+
installKeymap({ wm, palette });
|
|
136
|
+
|
|
137
|
+
// Chrome — painted into the ELEMENTS the embedder handed over. An absent
|
|
138
|
+
// key means absent chrome, not a crash.
|
|
139
|
+
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
140
|
+
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
141
|
+
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
142
|
+
// NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
|
|
143
|
+
// embedder's own machinery left on them) and returns the FRESH nodes. The
|
|
144
|
+
// originals are detached from here on — sync against the returned map, not
|
|
145
|
+
// against `chrome.panelToggles`.
|
|
146
|
+
toggles = bindPanelToggles(chrome.panelToggles, wm);
|
|
147
|
+
|
|
148
|
+
// Belt-and-braces: anything else that mutates the WM fans out to chrome.
|
|
149
|
+
eventBus?.on?.('wm:changed', syncChrome);
|
|
150
|
+
|
|
151
|
+
await wm.load(); // ← FIRST MOUNT
|
|
152
|
+
syncChrome();
|
|
153
|
+
|
|
154
|
+
log.info?.('shell mounted');
|
|
155
|
+
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
wm,
|
|
158
|
+
palette,
|
|
159
|
+
taxonomy,
|
|
160
|
+
entities,
|
|
161
|
+
root,
|
|
162
|
+
content: registry,
|
|
163
|
+
chrome: Object.freeze({
|
|
164
|
+
paletteButtonEl: paletteBtn,
|
|
165
|
+
topNavEl,
|
|
166
|
+
desktopsEl,
|
|
167
|
+
panelToggles: toggles,
|
|
168
|
+
}),
|
|
169
|
+
dispose: () => {
|
|
170
|
+
try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
// ══ Chrome painters ═══════════════════════════════════════════════════
|
|
177
|
+
// Each takes an ELEMENT (or nothing) and returns whatever the sync half
|
|
178
|
+
// needs. None of them queries the document.
|
|
179
|
+
|
|
180
|
+
/** One shortcut button per top-nav kind in the taxonomy, painted into the
|
|
181
|
+
* element the embedder gave us. Clicking opens that kind in the primary tile. */
|
|
182
|
+
function mountTopNav(hostEl, taxonomy, wm) {
|
|
183
|
+
if (!hostEl) return null;
|
|
184
|
+
hostEl.innerHTML = '';
|
|
185
|
+
hostEl.classList.add('twm-top-nav');
|
|
186
|
+
for (const k of taxonomy.topNavEntries()) {
|
|
187
|
+
const btn = document.createElement('button');
|
|
188
|
+
btn.className = 'twm-top-nav__btn twm-has-tooltip';
|
|
189
|
+
btn.dataset.kind = k.kind;
|
|
190
|
+
btn.dataset.tooltip = k.label;
|
|
191
|
+
btn.dataset.tooltipPlacement = 'bottom';
|
|
192
|
+
btn.setAttribute('aria-label', k.label);
|
|
193
|
+
btn.innerHTML = `
|
|
194
|
+
<span class="material-symbols-outlined twm-top-nav__icon">${k.icon}</span>
|
|
195
|
+
<span class="twm-top-nav__label">${k.label}</span>
|
|
196
|
+
`;
|
|
197
|
+
hostEl.appendChild(btn);
|
|
198
|
+
}
|
|
199
|
+
hostEl.addEventListener('click', (e) => {
|
|
200
|
+
const btn = e.target.closest('[data-kind]');
|
|
201
|
+
if (!btn) return;
|
|
202
|
+
wm.openInPrimary(btn.dataset.kind);
|
|
203
|
+
});
|
|
204
|
+
return hostEl;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function syncTopNav(hostEl, wm) {
|
|
208
|
+
if (!hostEl) return;
|
|
209
|
+
const tree = wm.desktops.active().tree;
|
|
210
|
+
const primaryId = tree.primaryLeafId();
|
|
211
|
+
const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
|
|
212
|
+
// topNavFor maps an entity kind onto its owning top-nav category. Falls
|
|
213
|
+
// back to the primary kind so unknown kinds at least try to match a button.
|
|
214
|
+
const topNavKind = wm.taxonomy.topNavFor(primaryKind) || primaryKind;
|
|
215
|
+
hostEl.querySelectorAll('[data-kind]').forEach((b) => {
|
|
216
|
+
b.classList.toggle('twm-top-nav__btn--on', b.dataset.kind === topNavKind);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mountPaletteButton(hostEl, palette) {
|
|
221
|
+
if (!hostEl) return null;
|
|
222
|
+
const existing = hostEl.querySelector('#twm-palette-btn');
|
|
223
|
+
if (existing) return existing;
|
|
224
|
+
const btn = document.createElement('button');
|
|
225
|
+
btn.id = 'twm-palette-btn';
|
|
226
|
+
btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
|
|
227
|
+
btn.dataset.tooltip = 'Command palette (Ctrl+K)';
|
|
228
|
+
btn.dataset.tooltipPlacement = 'bottom';
|
|
229
|
+
btn.setAttribute('aria-label', 'Open command palette');
|
|
230
|
+
btn.innerHTML = '<span class="material-symbols-outlined">menu_open</span>';
|
|
231
|
+
btn.addEventListener('click', () => palette.toggle());
|
|
232
|
+
hostEl.insertBefore(btn, hostEl.firstChild);
|
|
233
|
+
return btn;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Take ownership of the embedder's three panel-toggle buttons.
|
|
238
|
+
*
|
|
239
|
+
* cloneNode copies any state the embedder's own machinery left on a button
|
|
240
|
+
* (.active/.pinned/.disabled, the disabled attr, aria-pressed). We strip it so
|
|
241
|
+
* the WM is the sole, clean source of truth — `syncPanelToggles` manages only
|
|
242
|
+
* `.panel-toggle-btn--on` from here on. The clone keeps the id, so an embedder
|
|
243
|
+
* that drives a toggle by `getElementById(...).click()` still works.
|
|
244
|
+
*
|
|
245
|
+
* @returns {{left?:Element, right?:Element, bottom?:Element}} the FRESH nodes
|
|
246
|
+
*/
|
|
247
|
+
function bindPanelToggles(map, wm) {
|
|
248
|
+
const fresh = {};
|
|
249
|
+
for (const side of ['left', 'right', 'bottom']) {
|
|
250
|
+
const btn = map?.[side];
|
|
251
|
+
if (!btn) continue;
|
|
252
|
+
const el = btn.cloneNode(true);
|
|
253
|
+
el.classList.remove('active', 'pinned', 'disabled');
|
|
254
|
+
el.disabled = false;
|
|
255
|
+
el.removeAttribute('aria-disabled');
|
|
256
|
+
el.removeAttribute('aria-pressed');
|
|
257
|
+
btn.replaceWith(el);
|
|
258
|
+
el.addEventListener('click', () => wm.togglePanel(side));
|
|
259
|
+
fresh[side] = el;
|
|
260
|
+
}
|
|
261
|
+
return fresh;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function syncPanelToggles(map, wm) {
|
|
265
|
+
for (const side of ['left', 'right', 'bottom']) {
|
|
266
|
+
const btn = map?.[side];
|
|
267
|
+
if (!btn) continue;
|
|
268
|
+
btn.classList.toggle('twm-panel-toggle-btn--on', wm.isPanelOpen(side));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mountDesktopBar(hostEl, wm) {
|
|
273
|
+
if (!hostEl) return null;
|
|
274
|
+
let el = hostEl.querySelector('#twm-desktops');
|
|
275
|
+
if (!el) {
|
|
276
|
+
el = document.createElement('div');
|
|
277
|
+
el.id = 'twm-desktops';
|
|
278
|
+
el.className = 'twm-desktops';
|
|
279
|
+
hostEl.appendChild(el);
|
|
280
|
+
}
|
|
281
|
+
el.addEventListener('click', (e) => {
|
|
282
|
+
const btn = e.target.closest('[data-desktop]');
|
|
283
|
+
if (!btn) return;
|
|
284
|
+
const v = btn.dataset.desktop;
|
|
285
|
+
if (v === '+') { wm.addDesktop(); syncDesktopBar(el, wm); return; }
|
|
286
|
+
wm.switchDesktop(Number(v));
|
|
287
|
+
});
|
|
288
|
+
// Double-click a desktop chip → inline rename. Faster than the context
|
|
289
|
+
// menu's Rename… for the common case. Persists via wm._persist so the new
|
|
290
|
+
// label survives a restart.
|
|
291
|
+
el.addEventListener('dblclick', async (e) => {
|
|
292
|
+
const btn = e.target.closest('[data-desktop]');
|
|
293
|
+
if (!btn || btn.dataset.desktop === '+') return;
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
const idx = Number(btn.dataset.desktop);
|
|
296
|
+
const d = wm.desktops.desktops[idx];
|
|
297
|
+
if (!d) return;
|
|
298
|
+
const result = await openForm({
|
|
299
|
+
title: 'Rename desktop',
|
|
300
|
+
fields: [{ name: 'label', label: 'Name', type: 'text', required: true,
|
|
301
|
+
hint: 'Up to 24 characters. Shown in the bottom-bar chip and the Alt+N tooltip.' }],
|
|
302
|
+
defaults: { label: d.label },
|
|
303
|
+
submitLabel: 'Rename',
|
|
304
|
+
});
|
|
305
|
+
if (!result || !result.label) return;
|
|
306
|
+
d.label = String(result.label).slice(0, 24);
|
|
307
|
+
syncDesktopBar(el, wm);
|
|
308
|
+
wm._persist();
|
|
309
|
+
});
|
|
310
|
+
el.addEventListener('contextmenu', (e) => {
|
|
311
|
+
const btn = e.target.closest('[data-desktop]');
|
|
312
|
+
if (!btn || btn.dataset.desktop === '+') return;
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
const idx = Number(btn.dataset.desktop);
|
|
315
|
+
const m = wm.desktops;
|
|
316
|
+
const d = m.desktops[idx];
|
|
317
|
+
const canDelete = m.desktops.length > 1;
|
|
318
|
+
showContextMenu(e.clientX, e.clientY, [
|
|
319
|
+
{ label: `Switch to ${d.label}`, icon: 'desktop_windows',
|
|
320
|
+
action: 'switch', disabled: idx === m.activeIdx },
|
|
321
|
+
{ label: 'Rename…', icon: 'edit', action: 'rename' },
|
|
322
|
+
{ separator: true },
|
|
323
|
+
{ label: 'New desktop', icon: 'add', action: 'add' },
|
|
324
|
+
{ label: 'Delete desktop', icon: 'delete', action: 'delete',
|
|
325
|
+
danger: true, disabled: !canDelete },
|
|
326
|
+
], async (action) => {
|
|
327
|
+
if (action === 'switch') wm.switchDesktop(idx);
|
|
328
|
+
else if (action === 'rename') {
|
|
329
|
+
const result = await openForm({
|
|
330
|
+
title: 'Rename desktop',
|
|
331
|
+
fields: [{ name: 'label', label: 'Name', type: 'text', required: true }],
|
|
332
|
+
defaults: { label: d.label },
|
|
333
|
+
submitLabel: 'Rename',
|
|
334
|
+
});
|
|
335
|
+
if (!result || !result.label) return;
|
|
336
|
+
d.label = String(result.label).slice(0, 24);
|
|
337
|
+
syncDesktopBar(el, wm);
|
|
338
|
+
wm._persist();
|
|
339
|
+
}
|
|
340
|
+
else if (action === 'add') { wm.addDesktop(); syncDesktopBar(el, wm); }
|
|
341
|
+
else if (action === 'delete') { wm.removeDesktop(idx); syncDesktopBar(el, wm); }
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return el;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function syncDesktopBar(el, wm) {
|
|
348
|
+
if (!el) return;
|
|
349
|
+
const m = wm.desktops;
|
|
350
|
+
el.innerHTML =
|
|
351
|
+
m.desktops.map((d, i) => `
|
|
352
|
+
<button class="twm-desk__btn${i === m.activeIdx ? ' twm-desk__btn--on' : ''}"
|
|
353
|
+
data-desktop="${i}"
|
|
354
|
+
title="Desktop ${d.label} (Alt+${i + 1}) · right-click to delete">
|
|
355
|
+
${d.label}
|
|
356
|
+
</button>`).join('')
|
|
357
|
+
+ `<button class="twm-desk__btn twm-desk__btn--add"
|
|
358
|
+
data-desktop="+" title="Add desktop">+</button>`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
// ══ Tile menus ════════════════════════════════════════════════════════
|
|
363
|
+
|
|
364
|
+
/** Hamburger button in a tile's tab bar. Lists the same content the tile's
|
|
365
|
+
* home page would, with search + pagination. Clicking a row opens the entity
|
|
366
|
+
* in a NEW TAB on the ORIGINATING tile (the leaf id is captured at click
|
|
367
|
+
* time, so a focus change between click and pick doesn't reroute it). */
|
|
368
|
+
function _tileTabMenu(wm, leafId, x, y) {
|
|
369
|
+
const tree = wm.desktops.active().tree;
|
|
370
|
+
const leaf = tree.get(leafId);
|
|
371
|
+
if (!leaf) return;
|
|
372
|
+
const kind = leaf.content?.kind || wm.taxonomy.root;
|
|
373
|
+
openTileTabMenu({
|
|
374
|
+
x, y, leafKind: kind, api: wm.api,
|
|
375
|
+
taxonomy: wm.taxonomy, entities: wm.ctx.entities,
|
|
376
|
+
onPick: (navKind, shaped) => {
|
|
377
|
+
tree.appendLeafTab(leafId, {
|
|
378
|
+
kind: navKind,
|
|
379
|
+
props: { id: shaped.id, label: shaped.label },
|
|
380
|
+
}, shaped.label || shaped.id);
|
|
381
|
+
tree.focus(leafId);
|
|
382
|
+
wm.renderer.render();
|
|
383
|
+
wm._persist?.();
|
|
384
|
+
wm._notifyChange?.('tab-open-from-menu');
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Right-click on a tile's chrome strip: structural actions on that leaf. */
|
|
390
|
+
function _tileContextMenu(wm, leafId, x, y) {
|
|
391
|
+
const tree = wm.desktops.active().tree;
|
|
392
|
+
const leaf = tree.get(leafId);
|
|
393
|
+
if (!leaf) return;
|
|
394
|
+
const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
|
|
395
|
+
tree.focus(leafId);
|
|
396
|
+
wm.renderer._updateFocusClasses();
|
|
397
|
+
|
|
398
|
+
const items = [
|
|
399
|
+
{ label: 'Split horizontally', icon: 'splitscreen_vertical_add', action: 'split-h' },
|
|
400
|
+
{ label: 'Split vertically', icon: 'splitscreen_add', action: 'split-v' },
|
|
401
|
+
{ separator: true },
|
|
402
|
+
{ label: 'Open in new tab', icon: 'tab',
|
|
403
|
+
action: 'open-tab', disabled: isPanel || !leaf.content },
|
|
404
|
+
{ label: 'Open in new window', icon: 'open_in_full',
|
|
405
|
+
action: 'open-window', disabled: isPanel || !leaf.content },
|
|
406
|
+
{ label: 'Promote to window', icon: 'open_in_new',
|
|
407
|
+
action: 'promote', disabled: isPanel || !leaf.content },
|
|
408
|
+
];
|
|
409
|
+
if (wm.desktops.desktops.length > 1 && !isPanel) {
|
|
410
|
+
for (const [i, d] of wm.desktops.desktops.entries()) {
|
|
411
|
+
if (i === wm.desktops.activeIdx) continue;
|
|
412
|
+
items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
|
|
413
|
+
action: `move:${i}` });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
items.push({ separator: true });
|
|
417
|
+
items.push({ label: 'Close tile', icon: 'close', action: 'close',
|
|
418
|
+
danger: true, disabled: isPanel });
|
|
419
|
+
|
|
420
|
+
showContextMenu(x, y, items, (action) => {
|
|
421
|
+
if (action === 'split-h') wm.split('h');
|
|
422
|
+
else if (action === 'split-v') wm.split('v');
|
|
423
|
+
else if (action === 'open-tab') {
|
|
424
|
+
const c = leaf.content;
|
|
425
|
+
if (c) wm.openInTabFromContext({ leafId }, c.kind, c.props || {});
|
|
426
|
+
}
|
|
427
|
+
else if (action === 'open-window') {
|
|
428
|
+
const c = leaf.content;
|
|
429
|
+
if (c) wm.navigate(c.kind, c.props || {}, { target: 'window' });
|
|
430
|
+
}
|
|
431
|
+
else if (action === 'promote') wm.toggleManagedFocused();
|
|
432
|
+
else if (action === 'close') wm.closeFocused();
|
|
433
|
+
else if (action?.startsWith?.('move:')) {
|
|
434
|
+
wm.moveFocusedToDesktop(Number(action.slice(5)));
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tile_breadcrumb.js — per-tile breadcrumb strip rendered above each
|
|
3
|
+
* page's content.
|
|
4
|
+
*
|
|
5
|
+
* Reuses the existing `topbar-breadcrumb__*` CSS classes so the look
|
|
6
|
+
* matches the rest of the app (notebook editor, etc.). Segments are
|
|
7
|
+
* built from the leaf's content kind + props.id, and clicking a
|
|
8
|
+
* non-current segment routes through wm.navigate so the user jumps to
|
|
9
|
+
* that level.
|
|
10
|
+
*
|
|
11
|
+
* The path is assembled from FOUR taxonomy questions, none of which this
|
|
12
|
+
* module answers itself:
|
|
13
|
+
*
|
|
14
|
+
* 1. `ctx.rootCrumb` the leading segment + how its label stays fresh
|
|
15
|
+
* 2. `taxonomy.topNavFor` which section owns the kind
|
|
16
|
+
* 3. `taxonomy.ancestors` the entity-level chain above ONE instance
|
|
17
|
+
* 4. `taxonomy.labelOf` what to call that instance
|
|
18
|
+
*
|
|
19
|
+
* (3) is the one that matters. Deriving an instance's parent from its id
|
|
20
|
+
* is a naming convention, and naming conventions belong to the embedder;
|
|
21
|
+
* this file used to hardcode two of them.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Build a breadcrumb strip element + render initial state. Returns
|
|
25
|
+
* `{ el, destroy }`. The strip is wired to the WM (clicks open content
|
|
26
|
+
* in the primary tile) and, when the embedder supplies a `rootCrumb`
|
|
27
|
+
* with a `subscribe` hook, to whatever keeps the root label fresh. */
|
|
28
|
+
export function mountTileBreadcrumb(kind, props, ctx) {
|
|
29
|
+
const eventBus = ctx?.eventBus;
|
|
30
|
+
const taxonomy = ctx?.taxonomy;
|
|
31
|
+
const rootCrumb = ctx?.rootCrumb || null;
|
|
32
|
+
if (!taxonomy) {
|
|
33
|
+
console.error('[breadcrumb] no taxonomy in ctx — cannot render');
|
|
34
|
+
return { el: document.createElement('nav'), destroy: () => {} };
|
|
35
|
+
}
|
|
36
|
+
const root = document.createElement('nav');
|
|
37
|
+
root.className = 'twm-topbar-breadcrumb twm-tile-breadcrumb';
|
|
38
|
+
root.setAttribute('aria-label', 'Tile breadcrumb');
|
|
39
|
+
|
|
40
|
+
// Look up wm late (at click time) instead of capturing it once at
|
|
41
|
+
// mount. ctx.wm should always be set by the renderer, but fall
|
|
42
|
+
// back to the global escape hatch (set by install.js after
|
|
43
|
+
// wm.load()) so a closure that lost ctx.wm — or a mount called
|
|
44
|
+
// before the renderer threaded wm through — still navigates.
|
|
45
|
+
const getWm = () => ctx?.wm || window.__twm?.wm || null;
|
|
46
|
+
|
|
47
|
+
// Breadcrumb segments route through the canonical `wm.navigate`
|
|
48
|
+
// helper so the same three-axis routing (window → replace window,
|
|
49
|
+
// tile → replace active tab, panel/none → primary) lives in one
|
|
50
|
+
// place. Critically, this is what makes the breadcrumb work in
|
|
51
|
+
// WINDOWED mode — passing `ctx` keeps windowId in scope so the
|
|
52
|
+
// segment click stays inside the floating window instead of
|
|
53
|
+
// jumping back to the primary tile.
|
|
54
|
+
const navigate = (k, p = {}) => {
|
|
55
|
+
const wm = getWm();
|
|
56
|
+
if (!wm) {
|
|
57
|
+
console.error('[breadcrumb] no WM available — click ignored', k);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (typeof wm.navigate === 'function') {
|
|
61
|
+
wm.navigate(k, p, { ctx, dest: 'origin' });
|
|
62
|
+
} else if (typeof wm.openInPrimary === 'function') {
|
|
63
|
+
// Fallback for older WM revisions.
|
|
64
|
+
wm.openInPrimary(k, p);
|
|
65
|
+
} else {
|
|
66
|
+
console.error('[breadcrumb] WM has no navigate/openInPrimary');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// The root segment's label is the embedder's to supply and to keep
|
|
71
|
+
// fresh (it names whatever the root entity is — a project, a
|
|
72
|
+
// workspace, a document). Absent `rootCrumb` ⇒ no root segment, and
|
|
73
|
+
// the breadcrumb simply starts at the top-nav.
|
|
74
|
+
let rootLabel = rootCrumb?.label || '';
|
|
75
|
+
|
|
76
|
+
const render = () => {
|
|
77
|
+
_renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
|
|
78
|
+
};
|
|
79
|
+
render();
|
|
80
|
+
|
|
81
|
+
let unsubscribe = null;
|
|
82
|
+
if (typeof rootCrumb?.subscribe === 'function') {
|
|
83
|
+
try {
|
|
84
|
+
unsubscribe = rootCrumb.subscribe((next) => {
|
|
85
|
+
if (next?.label) rootLabel = next.label;
|
|
86
|
+
render();
|
|
87
|
+
}, { api: ctx?.api, host: ctx?.host, eventBus });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn('[breadcrumb] rootCrumb.subscribe threw', err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
el: root,
|
|
95
|
+
destroy: () => {
|
|
96
|
+
try { unsubscribe?.(); }
|
|
97
|
+
catch (err) { console.warn('[breadcrumb] rootCrumb teardown threw', err); }
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
103
|
+
const segs = [];
|
|
104
|
+
const meta = taxonomy.meta(kind);
|
|
105
|
+
|
|
106
|
+
// App-global kinds (e.g. a preferences page) aren't scoped to the
|
|
107
|
+
// root entity — they open with no project loaded and persist to
|
|
108
|
+
// app-level storage — so their breadcrumb has no root ancestor.
|
|
109
|
+
if (meta?.appGlobal) {
|
|
110
|
+
return [{ icon: meta.icon, label: meta.label, onClick: null }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 1. The root segment → clicks back to the root page.
|
|
114
|
+
if (rootCrumb) {
|
|
115
|
+
segs.push({
|
|
116
|
+
icon: rootCrumb.icon,
|
|
117
|
+
label: rootLabel || rootCrumb.label,
|
|
118
|
+
onClick: () => navigate(rootCrumb.navKind ?? taxonomy.root),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. The top-nav segment. Skipped on the root itself (the root
|
|
123
|
+
// segment already names it) and when the kind isn't in the
|
|
124
|
+
// taxonomy at all.
|
|
125
|
+
const topNav = taxonomy.topNavFor(kind);
|
|
126
|
+
const topNavMeta = topNav ? taxonomy.meta(topNav) : null;
|
|
127
|
+
if (topNav && topNav !== taxonomy.root && topNavMeta) {
|
|
128
|
+
segs.push({
|
|
129
|
+
icon: topNavMeta.icon,
|
|
130
|
+
label: topNavMeta.label,
|
|
131
|
+
onClick: () => navigate(topNav),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Entity-level ancestors, root-most first — supplied by the
|
|
136
|
+
// taxonomy, not by string surgery on the id.
|
|
137
|
+
for (const anc of taxonomy.ancestors(kind, props)) {
|
|
138
|
+
segs.push({
|
|
139
|
+
icon: taxonomy.meta(anc.kind)?.icon || 'description',
|
|
140
|
+
label: anc.props.label ?? anc.props.id,
|
|
141
|
+
onClick: () => navigate(anc.kind, anc.props),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. The node itself.
|
|
146
|
+
const hasEntity = !!props?.id;
|
|
147
|
+
if (meta) {
|
|
148
|
+
if (hasEntity) {
|
|
149
|
+
segs.push({
|
|
150
|
+
icon: meta.icon,
|
|
151
|
+
label: taxonomy.labelOf(kind, props),
|
|
152
|
+
onClick: null,
|
|
153
|
+
});
|
|
154
|
+
} else if (kind !== topNav) {
|
|
155
|
+
// The kind IS the top-nav (e.g. opening a landing page).
|
|
156
|
+
// Don't duplicate the chip — the segment above already
|
|
157
|
+
// names it.
|
|
158
|
+
segs.push({
|
|
159
|
+
icon: meta.icon,
|
|
160
|
+
label: meta.label,
|
|
161
|
+
onClick: null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} else if (hasEntity) {
|
|
165
|
+
// Kind isn't in the taxonomy yet — still surface the entity
|
|
166
|
+
// name so the user sees where they are, just without an icon.
|
|
167
|
+
segs.push({
|
|
168
|
+
icon: 'description',
|
|
169
|
+
label: props.label || props.id,
|
|
170
|
+
onClick: null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return segs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _renderInto(container, segments) {
|
|
177
|
+
container.innerHTML = '';
|
|
178
|
+
if (!segments?.length) return;
|
|
179
|
+
const ol = document.createElement('ol');
|
|
180
|
+
ol.className = 'twm-topbar-breadcrumb__list';
|
|
181
|
+
segments.forEach((seg, i) => {
|
|
182
|
+
const li = document.createElement('li');
|
|
183
|
+
li.className = 'twm-topbar-breadcrumb__item';
|
|
184
|
+
const isLast = i === segments.length - 1;
|
|
185
|
+
if (isLast || !seg.onClick) {
|
|
186
|
+
li.classList.add('twm-topbar-breadcrumb__item--current');
|
|
187
|
+
li.innerHTML = `
|
|
188
|
+
<span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc(seg.icon)}</span>
|
|
189
|
+
<span class="twm-topbar-breadcrumb__current">${_esc(seg.label)}</span>
|
|
190
|
+
`;
|
|
191
|
+
} else {
|
|
192
|
+
const btn = document.createElement('button');
|
|
193
|
+
btn.className = 'twm-topbar-breadcrumb__link';
|
|
194
|
+
btn.type = 'button';
|
|
195
|
+
btn.innerHTML = `
|
|
196
|
+
<span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc(seg.icon)}</span>
|
|
197
|
+
<span>${_esc(seg.label)}</span>
|
|
198
|
+
`;
|
|
199
|
+
btn.addEventListener('click', () => {
|
|
200
|
+
btn.classList.add('twm-tile-breadcrumb__link--flash');
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
btn.classList.remove('twm-tile-breadcrumb__link--flash');
|
|
203
|
+
}, 180);
|
|
204
|
+
try { seg.onClick(); }
|
|
205
|
+
catch (e) { console.error('[breadcrumb] segment click threw', e); }
|
|
206
|
+
});
|
|
207
|
+
li.appendChild(btn);
|
|
208
|
+
const sep = document.createElement('span');
|
|
209
|
+
sep.className = 'twm-topbar-breadcrumb__separator';
|
|
210
|
+
sep.textContent = '›';
|
|
211
|
+
li.appendChild(sep);
|
|
212
|
+
}
|
|
213
|
+
ol.appendChild(li);
|
|
214
|
+
});
|
|
215
|
+
container.appendChild(ol);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _esc(s) {
|
|
219
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
220
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
221
|
+
}[c]));
|
|
222
|
+
}
|