flexdesk 0.2.0 → 0.4.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/README.md +26 -0
- package/css/base.css +2243 -872
- package/css/flexdesk.css +1375 -14
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1375 -14
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +3140 -157
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +156 -25
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +115 -11
- package/src/tiling/wm.js +2375 -84
- package/src/tiling/zoom.js +248 -0
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/src/tiling/shell.js
CHANGED
|
@@ -43,7 +43,8 @@ import { WindowManager } from './wm.js';
|
|
|
43
43
|
import { createContentRegistry } from './content_registry.js';
|
|
44
44
|
import { createCommandPalette } from './command_palette.js';
|
|
45
45
|
import { installKeymap } from './keymap.js';
|
|
46
|
-
import {
|
|
46
|
+
import { openTileTabSwitcher } from './tile_tab_menu.js';
|
|
47
|
+
import { mountZoomControl } from './zoom.js';
|
|
47
48
|
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
48
49
|
import { openForm } from '../ui/components/modal.js';
|
|
49
50
|
|
|
@@ -64,9 +65,45 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
64
65
|
* @param {object} [cfg.tableStore] threaded into ctx for table persistence
|
|
65
66
|
* @param {object} [cfg.events] bus-event NAMES the WM listens for
|
|
66
67
|
* @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
|
|
67
|
-
* @param {object} [cfg.palette] { placeholder }
|
|
68
|
+
* @param {object} [cfg.palette] { placeholder, onPick } — C30. `onPick`
|
|
69
|
+
* is `(pick) => truthy` and CLAIMS the
|
|
70
|
+
* open of a picked entity, so an embedder
|
|
71
|
+
* with a rule about where its own entities
|
|
72
|
+
* belong applies that rule whichever door
|
|
73
|
+
* was used. Unclaimed picks still reset
|
|
74
|
+
* the primary tile: the behaviour every
|
|
75
|
+
* embedder has today.
|
|
76
|
+
* @param {object} [cfg.panels] C14. Which panel TILES a fresh desktop
|
|
77
|
+
* opens with — `{left, right, bottom}`,
|
|
78
|
+
* merged over all-three-open. An embedder
|
|
79
|
+
* whose navigator is its own chrome passes
|
|
80
|
+
* `{left: false, right: false}` and gets no
|
|
81
|
+
* tile it never registered a factory for.
|
|
82
|
+
* @param {boolean} [cfg.promoteInPlace] C21. Whether a window promoted out
|
|
83
|
+
* of a tile stays confined to that pane
|
|
84
|
+
* rather than floating over the whole root.
|
|
85
|
+
* @param {boolean} [cfg.snapPromotion] C15. Whether dragging a promoted
|
|
86
|
+
* window onto a tile puts it back in the
|
|
87
|
+
* tree. Default off — it changes what a
|
|
88
|
+
* drag to an edge does.
|
|
89
|
+
* @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
|
|
90
|
+
* tabs. `'bottom'` is the framework's own
|
|
91
|
+
* spreadsheet strip under the tile body
|
|
92
|
+
* and is the DEFAULT, so no existing
|
|
93
|
+
* embedder's panes rearrange on upgrade;
|
|
94
|
+
* `'top'` mounts the editor tab bar
|
|
95
|
+
* between the chrome and the body. The
|
|
96
|
+
* renderer mirrors this onto `root` as
|
|
97
|
+
* `data-twm-tabs` and watches it, so an
|
|
98
|
+
* embedder whose settings pane holds only
|
|
99
|
+
* the root element can change it live.
|
|
68
100
|
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
69
|
-
* panelToggles?: { left?, right?, bottom? }
|
|
101
|
+
* panelToggles?: { left?, right?, bottom? },
|
|
102
|
+
* zoom? }
|
|
103
|
+
* `zoom` is C31: the element the content
|
|
104
|
+
* zoom control is painted into — see
|
|
105
|
+
* zoom.js. Absent, there is no control and
|
|
106
|
+
* nothing is ever scaled.
|
|
70
107
|
* @returns {Promise<object>} the frozen shell
|
|
71
108
|
*/
|
|
72
109
|
export async function createShell({
|
|
@@ -82,7 +119,15 @@ export async function createShell({
|
|
|
82
119
|
events = {},
|
|
83
120
|
rootCrumb = null,
|
|
84
121
|
palette: paletteCfg = {},
|
|
122
|
+
panels = null,
|
|
123
|
+
snapPromotion = false,
|
|
124
|
+
promoteInPlace = false,
|
|
125
|
+
tabLayout = null,
|
|
85
126
|
chrome = {},
|
|
127
|
+
// An embedder that moved its sections out of the top bar — into an icon
|
|
128
|
+
// rail, say — passes the selector its own buttons match, and F1..F8 keep
|
|
129
|
+
// working. Omitted, the default top-bar selector applies.
|
|
130
|
+
navSelector = null,
|
|
86
131
|
} = {}) {
|
|
87
132
|
// A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
|
|
88
133
|
// createHost / createTaxonomy.
|
|
@@ -115,6 +160,10 @@ export async function createShell({
|
|
|
115
160
|
host,
|
|
116
161
|
taxonomy,
|
|
117
162
|
events,
|
|
163
|
+
panelDefaults: panels,
|
|
164
|
+
snapPromotion,
|
|
165
|
+
promoteInPlace,
|
|
166
|
+
tabLayout,
|
|
118
167
|
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
119
168
|
// spreads it into every content factory, which is how the breadcrumb
|
|
120
169
|
// gets `taxonomy` + `rootCrumb` without a content factory knowing they
|
|
@@ -132,13 +181,23 @@ export async function createShell({
|
|
|
132
181
|
const palette = createCommandPalette({
|
|
133
182
|
wm, api, taxonomy, catalog: entities, ...paletteCfg,
|
|
134
183
|
});
|
|
135
|
-
|
|
184
|
+
// `navSelector` lets an embedder that moved its sections out of the top
|
|
185
|
+
// bar keep F1..F8 working. Omitted, the default top-bar selector applies —
|
|
186
|
+
// the behaviour every existing embedder has today.
|
|
187
|
+
const disposeKeymap = installKeymap({ wm, palette, navSelector });
|
|
136
188
|
|
|
137
189
|
// Chrome — painted into the ELEMENTS the embedder handed over. An absent
|
|
138
190
|
// key means absent chrome, not a crash.
|
|
139
191
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
140
192
|
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
141
193
|
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
194
|
+
// C31. The content zoom. Painted with the rest of the chrome and, unlike the
|
|
195
|
+
// rest, AWAITED before the first mount: its saved value is applied to `root`
|
|
196
|
+
// as a CSS variable, and a shell that mounted first would paint every tile at
|
|
197
|
+
// 100% and then visibly jump to the user's zoom a moment later. A host with no
|
|
198
|
+
// saved zoom — or no `state` at all — resolves immediately to the default.
|
|
199
|
+
const zoom = mountZoomControl(chrome.zoom, { root, host });
|
|
200
|
+
if (zoom) await zoom.ready;
|
|
142
201
|
// NOTE: bindPanelToggles CLONES the buttons (to strip whatever state the
|
|
143
202
|
// embedder's own machinery left on them) and returns the FRESH nodes. The
|
|
144
203
|
// originals are detached from here on — sync against the returned map, not
|
|
@@ -165,9 +224,31 @@ export async function createShell({
|
|
|
165
224
|
topNavEl,
|
|
166
225
|
desktopsEl,
|
|
167
226
|
panelToggles: toggles,
|
|
227
|
+
// `get`/`set` so an embedder can drive the zoom from its own settings
|
|
228
|
+
// pane, or read it, without reaching into the control's DOM.
|
|
229
|
+
zoom,
|
|
168
230
|
}),
|
|
231
|
+
// A shell that can be built can be built TWICE — an embedder that
|
|
232
|
+
// rebuilds on a context change (a different project, a different
|
|
233
|
+
// workspace) does exactly that. Everything this function installs
|
|
234
|
+
// outside `root` has to come off, or the second shell shares the page
|
|
235
|
+
// with the first one's keyboard.
|
|
169
236
|
dispose: () => {
|
|
170
237
|
try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
|
|
238
|
+
try { disposeKeymap?.(); } catch { /* ignore */ }
|
|
239
|
+
try { palette?.close?.(); } catch { /* ignore */ }
|
|
240
|
+
// Every content factory gets its `destroy()`. Clearing the root
|
|
241
|
+
// element would detach the DOM and tell none of them, so an ERD's
|
|
242
|
+
// `window` keydown listener or a pane's interval would outlive the
|
|
243
|
+
// shell that mounted it. This also stops the renderer painting, so
|
|
244
|
+
// anything still holding a reference to this wm — a callback
|
|
245
|
+
// captured before a rebuild, a promise that has not settled —
|
|
246
|
+
// cannot repaint a dead tree into a root the live shell now owns.
|
|
247
|
+
try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
|
|
248
|
+
// The zoom lives on `root` as a variable and a class. A rebuilt shell on
|
|
249
|
+
// the same root must not inherit the old one's scale with no control on
|
|
250
|
+
// screen to change it, so dispose puts the root back to 100%.
|
|
251
|
+
try { zoom?.dispose(); } catch { /* ignore */ }
|
|
171
252
|
},
|
|
172
253
|
});
|
|
173
254
|
}
|
|
@@ -217,10 +298,23 @@ function syncTopNav(hostEl, wm) {
|
|
|
217
298
|
});
|
|
218
299
|
}
|
|
219
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The palette button, REBOUND rather than reused.
|
|
303
|
+
*
|
|
304
|
+
* This used to return an existing `#twm-palette-btn` untouched, which is right
|
|
305
|
+
* only while a page mounts one shell and keeps it. An embedder that rebuilds
|
|
306
|
+
* its shell — Tables does, on every project switch — got its FIRST shell's
|
|
307
|
+
* button back, still wired to the FIRST shell's palette. Clicking it opened a
|
|
308
|
+
* palette over a disposed window manager, and picking a result called
|
|
309
|
+
* `openInPrimary` on a tree nobody could see, whose renderer then painted it
|
|
310
|
+
* into the root the live shell now owns.
|
|
311
|
+
*
|
|
312
|
+
* So the stale node is replaced, which is exactly what `bindPanelToggles` does
|
|
313
|
+
* one function below and for the same reason.
|
|
314
|
+
*/
|
|
220
315
|
function mountPaletteButton(hostEl, palette) {
|
|
221
316
|
if (!hostEl) return null;
|
|
222
|
-
|
|
223
|
-
if (existing) return existing;
|
|
317
|
+
hostEl.querySelector('#twm-palette-btn')?.remove();
|
|
224
318
|
const btn = document.createElement('button');
|
|
225
319
|
btn.id = 'twm-palette-btn';
|
|
226
320
|
btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
|
|
@@ -361,27 +455,27 @@ function syncDesktopBar(el, wm) {
|
|
|
361
455
|
|
|
362
456
|
// ══ Tile menus ════════════════════════════════════════════════════════
|
|
363
457
|
|
|
364
|
-
/** Hamburger button in a tile's tab bar.
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* time, so a focus change between click
|
|
458
|
+
/** Hamburger button in a tile's tab bar. Opens a compact menu anchored to
|
|
459
|
+
* the button that lists the leaf's OPEN TABS for quick switching (a
|
|
460
|
+
* browser-style tab overflow list). Clicking a row activates that tab.
|
|
461
|
+
* The leaf id is captured at click time, so a focus change between click
|
|
462
|
+
* and pick doesn't reroute it. */
|
|
368
463
|
function _tileTabMenu(wm, leafId, x, y) {
|
|
369
464
|
const tree = wm.desktops.active().tree;
|
|
370
465
|
const leaf = tree.get(leafId);
|
|
371
|
-
if (!leaf) return;
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}, shaped.label || shaped.id);
|
|
466
|
+
if (!leaf || leaf.kind !== 'leaf') return;
|
|
467
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
468
|
+
if (tabs.length === 0) return;
|
|
469
|
+
openTileTabSwitcher({
|
|
470
|
+
x, y,
|
|
471
|
+
tabs,
|
|
472
|
+
activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
|
|
473
|
+
onPick: (idx) => {
|
|
474
|
+
tree.setActiveLeafTab(leafId, idx);
|
|
381
475
|
tree.focus(leafId);
|
|
382
476
|
wm.renderer.render();
|
|
383
477
|
wm._persist?.();
|
|
384
|
-
wm._notifyChange?.('tab-
|
|
478
|
+
wm._notifyChange?.('tab-switch-from-menu');
|
|
385
479
|
},
|
|
386
480
|
});
|
|
387
481
|
}
|
|
@@ -401,10 +495,36 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
401
495
|
{ separator: true },
|
|
402
496
|
{ label: 'Open in new tab', icon: 'tab',
|
|
403
497
|
action: 'open-tab', disabled: isPanel || !leaf.content },
|
|
404
|
-
|
|
498
|
+
// TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
|
|
499
|
+
// window INSIDE the application — the same glyph `ManagedWindow` uses
|
|
500
|
+
// for itself — and an embedder that can also send content to a real
|
|
501
|
+
// browser window keeps `open_in_new`, which is the universal "this
|
|
502
|
+
// leaves the page". One glyph for both is how a user learns that the
|
|
503
|
+
// two commands are the same command, and then loses a window looking
|
|
504
|
+
// for it on the other screen.
|
|
505
|
+
{ label: 'Open a copy in a window', icon: 'web_asset',
|
|
405
506
|
action: 'open-window', disabled: isPanel || !leaf.content },
|
|
406
|
-
|
|
407
|
-
|
|
507
|
+
// C20, THE OTHER HALF — and it was missing while the `close` half
|
|
508
|
+
// below carried a paragraph explaining why it could not be.
|
|
509
|
+
//
|
|
510
|
+
// `_floatableLeaf` (`wm.js`) refuses to float content that declared
|
|
511
|
+
// `chrome: { promote: false }`, and every door converges there — so
|
|
512
|
+
// this row offered the verb, enabled, and returned null. The chrome's
|
|
513
|
+
// own float BUTTON does not have the problem: C20 removes it from the
|
|
514
|
+
// strip. That asymmetry is what hid this: the affordance the reader
|
|
515
|
+
// checks is correct, and the menu one layer down is not.
|
|
516
|
+
//
|
|
517
|
+
// `=== false` EXACTLY, because that is the test the verb makes
|
|
518
|
+
// (`wm.js`, `_floatableLeaf`: *"content that says nothing about
|
|
519
|
+
// `promote` stays floatable"*). A falsy test here would grey the row
|
|
520
|
+
// on every leaf whose content returned no `chrome` at all, which is
|
|
521
|
+
// most of them — a menu disagreeing with its verb in the generous
|
|
522
|
+
// direction is a dead control; in the mean direction it is a missing
|
|
523
|
+
// feature, and this file has shipped one of each.
|
|
524
|
+
{ label: 'Float this pane as a window', icon: 'web_asset',
|
|
525
|
+
action: 'promote',
|
|
526
|
+
disabled: isPanel || !leaf.content
|
|
527
|
+
|| wm.renderer?.leafChrome?.(leafId)?.promote === false },
|
|
408
528
|
];
|
|
409
529
|
if (wm.desktops.desktops.length > 1 && !isPanel) {
|
|
410
530
|
for (const [i, d] of wm.desktops.desktops.entries()) {
|
|
@@ -414,8 +534,19 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
414
534
|
}
|
|
415
535
|
}
|
|
416
536
|
items.push({ separator: true });
|
|
537
|
+
// C20. THE MENU ROW CARRIES THE VETO, AND THE REASON WITH IT.
|
|
538
|
+
//
|
|
539
|
+
// `closeFocused` refuses when the content vetoed `close`, which is what
|
|
540
|
+
// makes the greyed × in the chrome honest — and left this row offering the
|
|
541
|
+
// same verb, enabled, doing nothing. A control that silently no-ops is the
|
|
542
|
+
// dead-control failure this file has fixed three times already. The
|
|
543
|
+
// tooltip is the content's own sentence, so the two doors explain the
|
|
544
|
+
// refusal identically rather than one explaining it and one not.
|
|
545
|
+
const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
|
|
546
|
+
const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
|
|
417
547
|
items.push({ label: 'Close tile', icon: 'close', action: 'close',
|
|
418
|
-
danger: true, disabled: isPanel
|
|
548
|
+
danger: true, disabled: isPanel || closeVetoed,
|
|
549
|
+
title: closeVetoed ? (closeVeto?.title || undefined) : undefined });
|
|
419
550
|
|
|
420
551
|
showContextMenu(x, y, items, (action) => {
|
|
421
552
|
if (action === 'split-h') wm.split('h');
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tab_strip.js — ONE tab strip, over one ordered list of tab specs.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is a module and not a method ────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* C22 taught `tile_renderer.js` to draw a leaf's tabs with `NotebookTabBar`,
|
|
7
|
+
* the editor's strip, and that code is not really about leaves: it is about
|
|
8
|
+
* translating between a component whose vocabulary is FILE PATHS and a list of
|
|
9
|
+
* `{kind, props, title}` that has no identity of its own. R8 needs exactly that
|
|
10
|
+
* translation a second time — a pane floated into a window takes its tab strip
|
|
11
|
+
* with it, because the pane IS its tabs and a window showing only one of them
|
|
12
|
+
* is not the pane — and the strip in the window is not attached to a leaf at
|
|
13
|
+
* all. Its tabs live in the WM's window record.
|
|
14
|
+
*
|
|
15
|
+
* So the translation moves here, where neither side owns it, and both sides
|
|
16
|
+
* reach it the same way. A second implementation of it in `wm.js` would be a
|
|
17
|
+
* second copy of the four corrections below, and they would drift apart
|
|
18
|
+
* silently: none of them is a syntax error and none throws at import.
|
|
19
|
+
*
|
|
20
|
+
* ── The four corrections, and why each one exists ────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 1. IDENTITY. `NotebookTabBar` keys everything — its reorder, its active
|
|
23
|
+
* comparison, its rename selector — on a file path. A tab list has no ids
|
|
24
|
+
* (the tree gives tabs none and neither does a window record), so the
|
|
25
|
+
* INDEX serves, and it is the one identity such a list is guaranteed to
|
|
26
|
+
* have. The `builtin://` prefix is not decoration: `#beginInlineRename`
|
|
27
|
+
* refuses to start on a path carrying it (`notebook_tab_bar.js:402`),
|
|
28
|
+
* which is what a tab needs — double-click rename would rename a TAB, and
|
|
29
|
+
* a tab is a view of an entity whose name lives somewhere neither caller
|
|
30
|
+
* can reach.
|
|
31
|
+
* 2. THE GLYPH. The component picks one out of a static map of FILE kinds.
|
|
32
|
+
* Ours are the embedder's kinds and the taxonomy already answers for them.
|
|
33
|
+
* `setFileTypeIcons()` is not the way: it is STATIC and REPLACES the whole
|
|
34
|
+
* map, so a page that also uses the editor would find its own file icons
|
|
35
|
+
* deleted by whichever of the two rendered last.
|
|
36
|
+
* 3. THE TOOLTIP. Its title attribute is the file PATH — ours would read
|
|
37
|
+
* `builtin://tab/3`, i.e. a tab advertising its own array index.
|
|
38
|
+
* 4. ONE TAB CONTEXT MENU IN THE APPLICATION, not two. The component opens
|
|
39
|
+
* its own `.nb-context-menu` whose verbs are a notebook's (rename,
|
|
40
|
+
* duplicate, reveal in explorer). The caller has a menu already; the
|
|
41
|
+
* component's is suppressed in the CAPTURE phase, because its handler is
|
|
42
|
+
* bound on each tab element in the bubble phase and stopping the event on
|
|
43
|
+
* the way DOWN is what keeps its menu from opening at all.
|
|
44
|
+
*
|
|
45
|
+
* The class names touched here are the component's published contract, stated
|
|
46
|
+
* in its own header comment.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
|
|
50
|
+
|
|
51
|
+
/** A tab's identity, for a component that only understands paths. See (1). */
|
|
52
|
+
export function tabKey(idx) { return `builtin://tab/${idx}`; }
|
|
53
|
+
|
|
54
|
+
/** …and back. `-1` for anything that is not one of ours. */
|
|
55
|
+
export function tabKeyIndex(key) {
|
|
56
|
+
const n = Number(String(key ?? '').replace('builtin://tab/', ''));
|
|
57
|
+
return Number.isInteger(n) && n >= 0 ? n : -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and every tab model here moves ONE
|
|
62
|
+
* tab at a time (`TileTree.moveLeafTab(leafId, from, to)`, and the window
|
|
63
|
+
* record's own splice). They reconcile because a drag only ever moves one
|
|
64
|
+
* element: every other key shifts by exactly one place, so the element that
|
|
65
|
+
* travelled furthest between the two orders IS the one that was dragged.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{from: number, to: number}|null} null when the order is not a
|
|
68
|
+
* permutation of `count` tabs, or when nothing actually moved.
|
|
69
|
+
*/
|
|
70
|
+
export function reorderToMove(order, count) {
|
|
71
|
+
if (!Array.isArray(order) || order.length !== count) return null;
|
|
72
|
+
let from = -1;
|
|
73
|
+
let to = -1;
|
|
74
|
+
let furthest = 0;
|
|
75
|
+
order.forEach((key, newIdx) => {
|
|
76
|
+
const oldIdx = tabKeyIndex(key);
|
|
77
|
+
if (oldIdx < 0) return;
|
|
78
|
+
const travelled = Math.abs(newIdx - oldIdx);
|
|
79
|
+
if (travelled > furthest) { furthest = travelled; from = oldIdx; to = newIdx; }
|
|
80
|
+
});
|
|
81
|
+
if (from < 0 || from === to) return null;
|
|
82
|
+
return { from, to };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Mount a tab strip into `hostEl` and drive it from a plain tab list.
|
|
87
|
+
*
|
|
88
|
+
* Every gesture leaves through ONE callback with the SAME vocabulary the tile
|
|
89
|
+
* renderer's two layouts already use — `switch` / `close` / `move` / `menu` —
|
|
90
|
+
* so a caller wires one function and the WM's tree mutations, its persistence
|
|
91
|
+
* and its change notifications stay reachable by one path from every strip in
|
|
92
|
+
* the application.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} cfg
|
|
95
|
+
* @param {HTMLElement} cfg.hostEl the bar element. The strip mounts into a
|
|
96
|
+
* CHILD of it, because `mount()` assigns `container.className = 'tabs
|
|
97
|
+
* notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it the bar would take
|
|
98
|
+
* that class, and with it the bar's height, background and `--hidden`, off
|
|
99
|
+
* the element the caller still controls.
|
|
100
|
+
* @param {object} [cfg.taxonomy] asked for each kind's glyph. Absent ⇒ the
|
|
101
|
+
* glyph is hidden rather than wrong.
|
|
102
|
+
* @param {(action: 'switch'|'close'|'move'|'menu', data: object) => void} cfg.onAction
|
|
103
|
+
* @returns {{update: (tabs: object[], activeIdx: number) => void, dispose: () => void}}
|
|
104
|
+
*/
|
|
105
|
+
export function createTabStrip({ hostEl, taxonomy = null, onAction }) {
|
|
106
|
+
const host = document.createElement('div');
|
|
107
|
+
hostEl.appendChild(host);
|
|
108
|
+
const strip = new NotebookTabBar();
|
|
109
|
+
let tabs = [];
|
|
110
|
+
|
|
111
|
+
strip.mount(host, {
|
|
112
|
+
onActivate: (key) => {
|
|
113
|
+
const idx = tabKeyIndex(key);
|
|
114
|
+
if (idx >= 0) onAction?.('switch', { idx });
|
|
115
|
+
},
|
|
116
|
+
onClose: (key) => {
|
|
117
|
+
const idx = tabKeyIndex(key);
|
|
118
|
+
if (idx >= 0) onAction?.('close', { idx });
|
|
119
|
+
},
|
|
120
|
+
onReorder: (order) => {
|
|
121
|
+
const move = reorderToMove(order, tabs.length);
|
|
122
|
+
if (move) onAction?.('move', move);
|
|
123
|
+
},
|
|
124
|
+
// Everything else the component offers that a tab here cannot honour is
|
|
125
|
+
// deliberately absent rather than stubbed — `onRename` is refused by the
|
|
126
|
+
// `builtin://` key, and the rest (`onDuplicate`, `onSplitRight`,
|
|
127
|
+
// `onRevealInExplorer`, `onCloseAll`, …) are only ever reached from the
|
|
128
|
+
// context menu suppressed below.
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// See (4). Capture phase, on the container.
|
|
132
|
+
const onContextMenu = (ev) => {
|
|
133
|
+
const tabEl = ev.target.closest?.('.tab');
|
|
134
|
+
if (!tabEl) return;
|
|
135
|
+
ev.preventDefault();
|
|
136
|
+
ev.stopPropagation();
|
|
137
|
+
const idx = Array.prototype.indexOf.call(host.querySelectorAll('.tab'), tabEl);
|
|
138
|
+
if (idx < 0) return;
|
|
139
|
+
onAction?.('menu', { idx, x: ev.clientX, y: ev.clientY });
|
|
140
|
+
};
|
|
141
|
+
host.addEventListener('contextmenu', onContextMenu, true);
|
|
142
|
+
|
|
143
|
+
/** See (2) and (3) — one pass over the DOM the component just wrote. */
|
|
144
|
+
const repaint = () => {
|
|
145
|
+
host.querySelectorAll('.tab').forEach((el, i) => {
|
|
146
|
+
const spec = tabs[i];
|
|
147
|
+
if (!spec) return;
|
|
148
|
+
el.title = spec.title || spec.kind || '';
|
|
149
|
+
const icon = spec.kind ? taxonomy?.meta?.(spec.kind)?.icon : null;
|
|
150
|
+
const glyph = el.querySelector('.tab-icon');
|
|
151
|
+
if (!glyph) return;
|
|
152
|
+
if (icon) { glyph.textContent = icon; glyph.hidden = false; }
|
|
153
|
+
else glyph.hidden = true;
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
/** @param {Array<{kind: string, props?: object, title?: string, dirty?: boolean}>} next */
|
|
159
|
+
update(next, activeIdx) {
|
|
160
|
+
tabs = Array.isArray(next) ? next : [];
|
|
161
|
+
const active = Math.max(0, Math.min(tabs.length - 1, activeIdx || 0));
|
|
162
|
+
strip.update(
|
|
163
|
+
tabs.map((t, i) => ({
|
|
164
|
+
filePath: tabKey(i),
|
|
165
|
+
label: t.title || t.kind || '',
|
|
166
|
+
fileType: t.kind || 'unknown',
|
|
167
|
+
// Nothing sets `dirty` on a tab spec today, so the dot is
|
|
168
|
+
// never drawn. Read anyway, because the day a tab can say it
|
|
169
|
+
// holds unsaved work this is where it says it, and the
|
|
170
|
+
// alternative is a second place to remember.
|
|
171
|
+
isDirty: !!t.dirty,
|
|
172
|
+
})),
|
|
173
|
+
tabKey(active));
|
|
174
|
+
repaint();
|
|
175
|
+
},
|
|
176
|
+
dispose() {
|
|
177
|
+
host.removeEventListener('contextmenu', onContextMenu, true);
|
|
178
|
+
try { strip.dispose(); } catch (err) {
|
|
179
|
+
console.error('[tab-strip] dispose threw', err);
|
|
180
|
+
}
|
|
181
|
+
host.remove();
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -73,8 +73,17 @@ export function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
73
73
|
// the breadcrumb simply starts at the top-nav.
|
|
74
74
|
let rootLabel = rootCrumb?.label || '';
|
|
75
75
|
|
|
76
|
+
// Read at RENDER time, not captured: the trail grows with every
|
|
77
|
+
// click-through, and a breadcrumb that showed the trail as it was when the
|
|
78
|
+
// tile mounted would be describing a journey the user has since continued.
|
|
79
|
+
const trailOf = () => {
|
|
80
|
+
if (typeof ctx?.trailSegments !== 'function') return [];
|
|
81
|
+
try { return ctx.trailSegments() || []; }
|
|
82
|
+
catch (err) { console.warn('[breadcrumb] trailSegments threw', err); return []; }
|
|
83
|
+
};
|
|
76
84
|
const render = () => {
|
|
77
|
-
_renderInto(root,
|
|
85
|
+
_renderInto(root,
|
|
86
|
+
_segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf()));
|
|
78
87
|
};
|
|
79
88
|
render();
|
|
80
89
|
|
|
@@ -92,6 +101,10 @@ export function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
92
101
|
|
|
93
102
|
return {
|
|
94
103
|
el: root,
|
|
104
|
+
/** Repaint. A trail-driven breadcrumb changes without the tile
|
|
105
|
+
* remounting — following a lookup replaces the active tab's content in
|
|
106
|
+
* place — so the embedder that grew the trail says when. */
|
|
107
|
+
refresh: render,
|
|
95
108
|
destroy: () => {
|
|
96
109
|
try { unsubscribe?.(); }
|
|
97
110
|
catch (err) { console.warn('[breadcrumb] rootCrumb teardown threw', err); }
|
|
@@ -99,7 +112,7 @@ export function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
99
112
|
};
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
115
|
+
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trail) {
|
|
103
116
|
const segs = [];
|
|
104
117
|
const meta = taxonomy.meta(kind);
|
|
105
118
|
|
|
@@ -132,6 +145,25 @@ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
|
132
145
|
});
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
// 2b. THE TRAIL. An embedder whose navigation is a walk rather than a
|
|
149
|
+
// descent — click a customer, follow a lookup to its region, follow
|
|
150
|
+
// that to a country — has a real path that the taxonomy cannot know,
|
|
151
|
+
// because none of those is an ANCESTOR of the next. The tree already
|
|
152
|
+
// records it: every in-tile navigation pushes the outgoing content
|
|
153
|
+
// onto the active tab's `history` stack, which is what Backspace pops.
|
|
154
|
+
//
|
|
155
|
+
// `ctx.trailSegments` hands that stack over, already shaped. Absent, or
|
|
156
|
+
// empty, this is a no-op and the breadcrumb is exactly the ancestor
|
|
157
|
+
// walk it has always been.
|
|
158
|
+
for (const step of (trail || [])) {
|
|
159
|
+
if (!step?.kind) continue;
|
|
160
|
+
segs.push({
|
|
161
|
+
icon: taxonomy.meta(step.kind)?.icon || 'description',
|
|
162
|
+
label: step.title || step.props?.label || step.props?.id || step.kind,
|
|
163
|
+
onClick: () => navigate(step.kind, step.props || {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
135
167
|
// 3. Entity-level ancestors, root-most first — supplied by the
|
|
136
168
|
// taxonomy, not by string surgery on the id.
|
|
137
169
|
for (const anc of taxonomy.ancestors(kind, props)) {
|