flexdesk 0.2.0 → 0.3.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/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- 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 +1311 -18
- 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 +2983 -142
- 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 +135 -24
- 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 +82 -0
- package/src/tiling/wm.js +2352 -74
- 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,7 @@ 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
47
|
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
48
48
|
import { openForm } from '../ui/components/modal.js';
|
|
49
49
|
|
|
@@ -64,7 +64,38 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
64
64
|
* @param {object} [cfg.tableStore] threaded into ctx for table persistence
|
|
65
65
|
* @param {object} [cfg.events] bus-event NAMES the WM listens for
|
|
66
66
|
* @param {object} [cfg.rootCrumb] the breadcrumb's leading segment
|
|
67
|
-
* @param {object} [cfg.palette] { placeholder }
|
|
67
|
+
* @param {object} [cfg.palette] { placeholder, onPick } — C30. `onPick`
|
|
68
|
+
* is `(pick) => truthy` and CLAIMS the
|
|
69
|
+
* open of a picked entity, so an embedder
|
|
70
|
+
* with a rule about where its own entities
|
|
71
|
+
* belong applies that rule whichever door
|
|
72
|
+
* was used. Unclaimed picks still reset
|
|
73
|
+
* the primary tile: the behaviour every
|
|
74
|
+
* embedder has today.
|
|
75
|
+
* @param {object} [cfg.panels] C14. Which panel TILES a fresh desktop
|
|
76
|
+
* opens with — `{left, right, bottom}`,
|
|
77
|
+
* merged over all-three-open. An embedder
|
|
78
|
+
* whose navigator is its own chrome passes
|
|
79
|
+
* `{left: false, right: false}` and gets no
|
|
80
|
+
* tile it never registered a factory for.
|
|
81
|
+
* @param {boolean} [cfg.promoteInPlace] C21. Whether a window promoted out
|
|
82
|
+
* of a tile stays confined to that pane
|
|
83
|
+
* rather than floating over the whole root.
|
|
84
|
+
* @param {boolean} [cfg.snapPromotion] C15. Whether dragging a promoted
|
|
85
|
+
* window onto a tile puts it back in the
|
|
86
|
+
* tree. Default off — it changes what a
|
|
87
|
+
* drag to an edge does.
|
|
88
|
+
* @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
|
|
89
|
+
* tabs. `'bottom'` is the framework's own
|
|
90
|
+
* spreadsheet strip under the tile body
|
|
91
|
+
* and is the DEFAULT, so no existing
|
|
92
|
+
* embedder's panes rearrange on upgrade;
|
|
93
|
+
* `'top'` mounts the editor tab bar
|
|
94
|
+
* between the chrome and the body. The
|
|
95
|
+
* renderer mirrors this onto `root` as
|
|
96
|
+
* `data-twm-tabs` and watches it, so an
|
|
97
|
+
* embedder whose settings pane holds only
|
|
98
|
+
* the root element can change it live.
|
|
68
99
|
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
69
100
|
* panelToggles?: { left?, right?, bottom? } }
|
|
70
101
|
* @returns {Promise<object>} the frozen shell
|
|
@@ -82,7 +113,15 @@ export async function createShell({
|
|
|
82
113
|
events = {},
|
|
83
114
|
rootCrumb = null,
|
|
84
115
|
palette: paletteCfg = {},
|
|
116
|
+
panels = null,
|
|
117
|
+
snapPromotion = false,
|
|
118
|
+
promoteInPlace = false,
|
|
119
|
+
tabLayout = null,
|
|
85
120
|
chrome = {},
|
|
121
|
+
// An embedder that moved its sections out of the top bar — into an icon
|
|
122
|
+
// rail, say — passes the selector its own buttons match, and F1..F8 keep
|
|
123
|
+
// working. Omitted, the default top-bar selector applies.
|
|
124
|
+
navSelector = null,
|
|
86
125
|
} = {}) {
|
|
87
126
|
// A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
|
|
88
127
|
// createHost / createTaxonomy.
|
|
@@ -115,6 +154,10 @@ export async function createShell({
|
|
|
115
154
|
host,
|
|
116
155
|
taxonomy,
|
|
117
156
|
events,
|
|
157
|
+
panelDefaults: panels,
|
|
158
|
+
snapPromotion,
|
|
159
|
+
promoteInPlace,
|
|
160
|
+
tabLayout,
|
|
118
161
|
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
119
162
|
// spreads it into every content factory, which is how the breadcrumb
|
|
120
163
|
// gets `taxonomy` + `rootCrumb` without a content factory knowing they
|
|
@@ -132,7 +175,10 @@ export async function createShell({
|
|
|
132
175
|
const palette = createCommandPalette({
|
|
133
176
|
wm, api, taxonomy, catalog: entities, ...paletteCfg,
|
|
134
177
|
});
|
|
135
|
-
|
|
178
|
+
// `navSelector` lets an embedder that moved its sections out of the top
|
|
179
|
+
// bar keep F1..F8 working. Omitted, the default top-bar selector applies —
|
|
180
|
+
// the behaviour every existing embedder has today.
|
|
181
|
+
const disposeKeymap = installKeymap({ wm, palette, navSelector });
|
|
136
182
|
|
|
137
183
|
// Chrome — painted into the ELEMENTS the embedder handed over. An absent
|
|
138
184
|
// key means absent chrome, not a crash.
|
|
@@ -166,8 +212,23 @@ export async function createShell({
|
|
|
166
212
|
desktopsEl,
|
|
167
213
|
panelToggles: toggles,
|
|
168
214
|
}),
|
|
215
|
+
// A shell that can be built can be built TWICE — an embedder that
|
|
216
|
+
// rebuilds on a context change (a different project, a different
|
|
217
|
+
// workspace) does exactly that. Everything this function installs
|
|
218
|
+
// outside `root` has to come off, or the second shell shares the page
|
|
219
|
+
// with the first one's keyboard.
|
|
169
220
|
dispose: () => {
|
|
170
221
|
try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
|
|
222
|
+
try { disposeKeymap?.(); } catch { /* ignore */ }
|
|
223
|
+
try { palette?.close?.(); } catch { /* ignore */ }
|
|
224
|
+
// Every content factory gets its `destroy()`. Clearing the root
|
|
225
|
+
// element would detach the DOM and tell none of them, so an ERD's
|
|
226
|
+
// `window` keydown listener or a pane's interval would outlive the
|
|
227
|
+
// shell that mounted it. This also stops the renderer painting, so
|
|
228
|
+
// anything still holding a reference to this wm — a callback
|
|
229
|
+
// captured before a rebuild, a promise that has not settled —
|
|
230
|
+
// cannot repaint a dead tree into a root the live shell now owns.
|
|
231
|
+
try { wm.renderer.destroy(); } catch (err) { log.warn?.('renderer teardown', err); }
|
|
171
232
|
},
|
|
172
233
|
});
|
|
173
234
|
}
|
|
@@ -217,10 +278,23 @@ function syncTopNav(hostEl, wm) {
|
|
|
217
278
|
});
|
|
218
279
|
}
|
|
219
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The palette button, REBOUND rather than reused.
|
|
283
|
+
*
|
|
284
|
+
* This used to return an existing `#twm-palette-btn` untouched, which is right
|
|
285
|
+
* only while a page mounts one shell and keeps it. An embedder that rebuilds
|
|
286
|
+
* its shell — Tables does, on every project switch — got its FIRST shell's
|
|
287
|
+
* button back, still wired to the FIRST shell's palette. Clicking it opened a
|
|
288
|
+
* palette over a disposed window manager, and picking a result called
|
|
289
|
+
* `openInPrimary` on a tree nobody could see, whose renderer then painted it
|
|
290
|
+
* into the root the live shell now owns.
|
|
291
|
+
*
|
|
292
|
+
* So the stale node is replaced, which is exactly what `bindPanelToggles` does
|
|
293
|
+
* one function below and for the same reason.
|
|
294
|
+
*/
|
|
220
295
|
function mountPaletteButton(hostEl, palette) {
|
|
221
296
|
if (!hostEl) return null;
|
|
222
|
-
|
|
223
|
-
if (existing) return existing;
|
|
297
|
+
hostEl.querySelector('#twm-palette-btn')?.remove();
|
|
224
298
|
const btn = document.createElement('button');
|
|
225
299
|
btn.id = 'twm-palette-btn';
|
|
226
300
|
btn.className = 'twm-panel-toggle-btn twm-has-tooltip';
|
|
@@ -361,27 +435,27 @@ function syncDesktopBar(el, wm) {
|
|
|
361
435
|
|
|
362
436
|
// ══ Tile menus ════════════════════════════════════════════════════════
|
|
363
437
|
|
|
364
|
-
/** Hamburger button in a tile's tab bar.
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
* time, so a focus change between click
|
|
438
|
+
/** Hamburger button in a tile's tab bar. Opens a compact menu anchored to
|
|
439
|
+
* the button that lists the leaf's OPEN TABS for quick switching (a
|
|
440
|
+
* browser-style tab overflow list). Clicking a row activates that tab.
|
|
441
|
+
* The leaf id is captured at click time, so a focus change between click
|
|
442
|
+
* and pick doesn't reroute it. */
|
|
368
443
|
function _tileTabMenu(wm, leafId, x, y) {
|
|
369
444
|
const tree = wm.desktops.active().tree;
|
|
370
445
|
const leaf = tree.get(leafId);
|
|
371
|
-
if (!leaf) return;
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}, shaped.label || shaped.id);
|
|
446
|
+
if (!leaf || leaf.kind !== 'leaf') return;
|
|
447
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
448
|
+
if (tabs.length === 0) return;
|
|
449
|
+
openTileTabSwitcher({
|
|
450
|
+
x, y,
|
|
451
|
+
tabs,
|
|
452
|
+
activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
|
|
453
|
+
onPick: (idx) => {
|
|
454
|
+
tree.setActiveLeafTab(leafId, idx);
|
|
381
455
|
tree.focus(leafId);
|
|
382
456
|
wm.renderer.render();
|
|
383
457
|
wm._persist?.();
|
|
384
|
-
wm._notifyChange?.('tab-
|
|
458
|
+
wm._notifyChange?.('tab-switch-from-menu');
|
|
385
459
|
},
|
|
386
460
|
});
|
|
387
461
|
}
|
|
@@ -401,10 +475,36 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
401
475
|
{ separator: true },
|
|
402
476
|
{ label: 'Open in new tab', icon: 'tab',
|
|
403
477
|
action: 'open-tab', disabled: isPanel || !leaf.content },
|
|
404
|
-
|
|
478
|
+
// TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
|
|
479
|
+
// window INSIDE the application — the same glyph `ManagedWindow` uses
|
|
480
|
+
// for itself — and an embedder that can also send content to a real
|
|
481
|
+
// browser window keeps `open_in_new`, which is the universal "this
|
|
482
|
+
// leaves the page". One glyph for both is how a user learns that the
|
|
483
|
+
// two commands are the same command, and then loses a window looking
|
|
484
|
+
// for it on the other screen.
|
|
485
|
+
{ label: 'Open a copy in a window', icon: 'web_asset',
|
|
405
486
|
action: 'open-window', disabled: isPanel || !leaf.content },
|
|
406
|
-
|
|
407
|
-
|
|
487
|
+
// C20, THE OTHER HALF — and it was missing while the `close` half
|
|
488
|
+
// below carried a paragraph explaining why it could not be.
|
|
489
|
+
//
|
|
490
|
+
// `_floatableLeaf` (`wm.js`) refuses to float content that declared
|
|
491
|
+
// `chrome: { promote: false }`, and every door converges there — so
|
|
492
|
+
// this row offered the verb, enabled, and returned null. The chrome's
|
|
493
|
+
// own float BUTTON does not have the problem: C20 removes it from the
|
|
494
|
+
// strip. That asymmetry is what hid this: the affordance the reader
|
|
495
|
+
// checks is correct, and the menu one layer down is not.
|
|
496
|
+
//
|
|
497
|
+
// `=== false` EXACTLY, because that is the test the verb makes
|
|
498
|
+
// (`wm.js`, `_floatableLeaf`: *"content that says nothing about
|
|
499
|
+
// `promote` stays floatable"*). A falsy test here would grey the row
|
|
500
|
+
// on every leaf whose content returned no `chrome` at all, which is
|
|
501
|
+
// most of them — a menu disagreeing with its verb in the generous
|
|
502
|
+
// direction is a dead control; in the mean direction it is a missing
|
|
503
|
+
// feature, and this file has shipped one of each.
|
|
504
|
+
{ label: 'Float this pane as a window', icon: 'web_asset',
|
|
505
|
+
action: 'promote',
|
|
506
|
+
disabled: isPanel || !leaf.content
|
|
507
|
+
|| wm.renderer?.leafChrome?.(leafId)?.promote === false },
|
|
408
508
|
];
|
|
409
509
|
if (wm.desktops.desktops.length > 1 && !isPanel) {
|
|
410
510
|
for (const [i, d] of wm.desktops.desktops.entries()) {
|
|
@@ -414,8 +514,19 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
414
514
|
}
|
|
415
515
|
}
|
|
416
516
|
items.push({ separator: true });
|
|
517
|
+
// C20. THE MENU ROW CARRIES THE VETO, AND THE REASON WITH IT.
|
|
518
|
+
//
|
|
519
|
+
// `closeFocused` refuses when the content vetoed `close`, which is what
|
|
520
|
+
// makes the greyed × in the chrome honest — and left this row offering the
|
|
521
|
+
// same verb, enabled, doing nothing. A control that silently no-ops is the
|
|
522
|
+
// dead-control failure this file has fixed three times already. The
|
|
523
|
+
// tooltip is the content's own sentence, so the two doors explain the
|
|
524
|
+
// refusal identically rather than one explaining it and one not.
|
|
525
|
+
const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
|
|
526
|
+
const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
|
|
417
527
|
items.push({ label: 'Close tile', icon: 'close', action: 'close',
|
|
418
|
-
danger: true, disabled: isPanel
|
|
528
|
+
danger: true, disabled: isPanel || closeVetoed,
|
|
529
|
+
title: closeVetoed ? (closeVeto?.title || undefined) : undefined });
|
|
419
530
|
|
|
420
531
|
showContextMenu(x, y, items, (action) => {
|
|
421
532
|
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)) {
|