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/wm.js
CHANGED
|
@@ -21,6 +21,7 @@ import { PLACEHOLDER_KIND } from './content_registry.js';
|
|
|
21
21
|
import { installPanelKeyRouter } from './panel_keys.js';
|
|
22
22
|
import { ManagedWindow } from '../ui/components/managed_window.js';
|
|
23
23
|
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
24
|
+
import { createTabStrip } from './tab_strip.js';
|
|
24
25
|
|
|
25
26
|
const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
|
|
26
27
|
|
|
@@ -31,7 +32,9 @@ const PANEL_TITLES = {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
export class WindowManager {
|
|
34
|
-
constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content
|
|
35
|
+
constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content,
|
|
36
|
+
panelDefaults = null, snapPromotion = false,
|
|
37
|
+
promoteInPlace = false, tabLayout = null }) {
|
|
35
38
|
if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
|
|
36
39
|
if (!content || typeof content.mount !== 'function') {
|
|
37
40
|
throw new Error('WindowManager: a content registry is required '
|
|
@@ -66,11 +69,35 @@ export class WindowManager {
|
|
|
66
69
|
title: this.taxonomy.meta(kind)?.label || kind,
|
|
67
70
|
};
|
|
68
71
|
};
|
|
69
|
-
|
|
72
|
+
// C14. Which panel tiles a fresh desktop opens with. An embedder whose
|
|
73
|
+
// navigator and inspector live outside the WM root passes `false` for
|
|
74
|
+
// them and gets no placeholder tiles it never registered a factory for.
|
|
75
|
+
this.panelDefaults = panelDefaults || null;
|
|
76
|
+
/** C15. Whether a window this WM promoted may be dropped back INTO the
|
|
77
|
+
* tree. Default off: it changes what a drag to an edge does, and an
|
|
78
|
+
* existing embedder upgrading the library must not find its promoted
|
|
79
|
+
* windows behaving differently. */
|
|
80
|
+
this.snapPromotion = !!snapPromotion;
|
|
81
|
+
/** Whether a window promoted out of a tile stays CONFINED to the pane it
|
|
82
|
+
* came from, rather than floating over the whole root. Default off: it
|
|
83
|
+
* changes where a promoted window can be dragged, and an existing
|
|
84
|
+
* embedder upgrading must not find its windows suddenly clipped. */
|
|
85
|
+
this.promoteInPlace = !!promoteInPlace;
|
|
86
|
+
this._snapCtl = null;
|
|
87
|
+
this.desktops = new DesktopManager({
|
|
88
|
+
seed: this._rootLeaf, panelDefaults: this.panelDefaults });
|
|
70
89
|
this.renderer = new TileRenderer({
|
|
71
90
|
root: rootEl,
|
|
72
91
|
tree: this.desktops.active().tree,
|
|
73
92
|
content,
|
|
93
|
+
/** C22. Where a multi-tab leaf draws its tabs — `'bottom'`
|
|
94
|
+
* (the framework's own spreadsheet strip, and the DEFAULT so no
|
|
95
|
+
* existing embedder's panes rearrange on upgrade) or `'top'`
|
|
96
|
+
* (the editor tab bar, between the chrome and the body).
|
|
97
|
+
* The renderer also mirrors this onto the root as
|
|
98
|
+
* `data-twm-tabs` and watches it, so an embedder can change it
|
|
99
|
+
* live without holding a renderer reference. */
|
|
100
|
+
tabLayout,
|
|
74
101
|
ctx: {
|
|
75
102
|
...this.ctx,
|
|
76
103
|
wm: this,
|
|
@@ -79,6 +106,7 @@ export class WindowManager {
|
|
|
79
106
|
this._leafTabAction(leafId, action, data),
|
|
80
107
|
},
|
|
81
108
|
onFocusChange: () => this._notifyChange(),
|
|
109
|
+
onAfterRender: () => this._rehomeContainedWindows(),
|
|
82
110
|
});
|
|
83
111
|
this._persistTimer = null;
|
|
84
112
|
// Window-id → leaf-id mapping so a managed-window demote can
|
|
@@ -115,6 +143,17 @@ export class WindowManager {
|
|
|
115
143
|
}
|
|
116
144
|
}
|
|
117
145
|
|
|
146
|
+
/** C22. Change the tab layout of every tile, live. Delegates to the
|
|
147
|
+
* renderer, which moves each strip rather than rebuilding the tiles — so
|
|
148
|
+
* nothing mounted in a tile is unmounted and no staged work is lost.
|
|
149
|
+
*
|
|
150
|
+
* Not persisted here: which layout a user prefers is a USER setting, and
|
|
151
|
+
* the WM persists LAYOUT (`desktops`). An embedder that stores it does so
|
|
152
|
+
* under its own key and passes it back as `createShell({ tabLayout })`. */
|
|
153
|
+
setTabLayout(layout) {
|
|
154
|
+
this.renderer.setTabLayout(layout);
|
|
155
|
+
}
|
|
156
|
+
|
|
118
157
|
/** Emit on bus + call onChange. Use this instead of the bare callback
|
|
119
158
|
* so other surfaces (palette, top-bar toggles, page shortcuts) can
|
|
120
159
|
* subscribe through the existing event system. */
|
|
@@ -145,7 +184,8 @@ export class WindowManager {
|
|
|
145
184
|
async load() {
|
|
146
185
|
const blob = await loadDesktops(this.host?.state);
|
|
147
186
|
if (blob) {
|
|
148
|
-
this.desktops = DesktopManager.deserialize(blob, {
|
|
187
|
+
this.desktops = DesktopManager.deserialize(blob, {
|
|
188
|
+
seed: this._rootLeaf, panelDefaults: this.panelDefaults });
|
|
149
189
|
this.renderer.tree = this.desktops.active().tree;
|
|
150
190
|
}
|
|
151
191
|
// Normalize: managed windows don't survive a reload, so any
|
|
@@ -287,7 +327,20 @@ export class WindowManager {
|
|
|
287
327
|
updateActiveTabProps(leafId, patch) {
|
|
288
328
|
const tree = this._tree();
|
|
289
329
|
if (!tree?.updateActiveTabProps) return;
|
|
330
|
+
// READ BEFORE THE WRITE. This is what `rebaselineLeaf` checks the
|
|
331
|
+
// cached entry against, and it is the whole safety of accepting a props
|
|
332
|
+
// change onto a live tile: if the mounted wrap is not still at the key
|
|
333
|
+
// the tree had a moment ago, something else changed that this call has
|
|
334
|
+
// no business swallowing.
|
|
335
|
+
const expected = this.renderer?.leafKey?.(leafId) ?? undefined;
|
|
290
336
|
tree.updateActiveTabProps(leafId, patch);
|
|
337
|
+
// C34. THE TILE THAT SAVED ITS STATE MUST NOT BE REBUILT BY SAVING IT.
|
|
338
|
+
// The renderer caches a leaf's wrap under a key that includes every
|
|
339
|
+
// tab's props, so this write invalidated the tile it was called from —
|
|
340
|
+
// not here, but on whatever repainted next, which made it look like an
|
|
341
|
+
// unrelated bug. `rebaselineLeaf` accepts the new props onto the live
|
|
342
|
+
// entry; the argument is in its docstring.
|
|
343
|
+
this.renderer?.rebaselineLeaf?.(leafId, expected);
|
|
291
344
|
this._persist();
|
|
292
345
|
}
|
|
293
346
|
|
|
@@ -364,26 +417,39 @@ export class WindowManager {
|
|
|
364
417
|
}
|
|
365
418
|
|
|
366
419
|
/** Replace a managed window's content in place. Tears down the
|
|
367
|
-
* previous mount, mounts the new kind into the same
|
|
420
|
+
* previous mount, mounts the new kind into the same body element,
|
|
368
421
|
* and updates the window's title. */
|
|
369
422
|
openInWindow(winId, kind, props = {}) {
|
|
370
423
|
const rec = this._windowToLeaf.get(winId);
|
|
371
424
|
if (!rec) { this.openInPrimary(kind, props); return; }
|
|
372
425
|
try { rec.mountInfo?.destroy?.(); } catch {}
|
|
373
|
-
|
|
374
|
-
|
|
426
|
+
// R8. THE BODY, NOT THE CONTENT ELEMENT. A floated pane's window holds
|
|
427
|
+
// a tab strip above its body, and both are children of `contentEl` —
|
|
428
|
+
// so clearing `contentEl` here would delete the strip, silently, on
|
|
429
|
+
// the first in-window navigation, and the window would keep every one
|
|
430
|
+
// of its tabs in a list nothing could reach. `_navigateWindow` builds
|
|
431
|
+
// a window with no strip and no body, hence the fallback.
|
|
432
|
+
const host = rec.bodyEl || rec.contentEl;
|
|
433
|
+
host.innerHTML = '';
|
|
434
|
+
const mountInfo = this.content.mount(kind, host, props,
|
|
375
435
|
{ ...this.ctx, wm: this, windowId: winId });
|
|
376
436
|
rec.mountInfo = mountInfo;
|
|
377
437
|
rec.original = {
|
|
378
438
|
kind, props: { ...(props || {}) },
|
|
379
439
|
title: mountInfo?.title || kind,
|
|
380
440
|
};
|
|
441
|
+
// R8. And the TAB the window is showing is now that content, so the
|
|
442
|
+
// strip renames with it and a later dock puts back what is on screen
|
|
443
|
+
// rather than what the tab was called when it was promoted.
|
|
444
|
+
const tab = (rec.tabs || [])[rec.activeTabIdx];
|
|
445
|
+
if (tab) {
|
|
446
|
+
tab.kind = kind;
|
|
447
|
+
tab.props = { ...(props || {}) };
|
|
448
|
+
tab.title = rec.original.title;
|
|
449
|
+
rec.strip?.update(rec.tabs, rec.activeTabIdx);
|
|
450
|
+
}
|
|
381
451
|
// Update window title (DOM + the ManagedWindow instance).
|
|
382
|
-
|
|
383
|
-
const titleEl = rec.window.element?.querySelector('.twm-managed-window__title');
|
|
384
|
-
if (titleEl) titleEl.textContent = rec.original.title;
|
|
385
|
-
if (rec.window) rec.window.title = rec.original.title;
|
|
386
|
-
} catch {}
|
|
452
|
+
this._setWindowTitle(rec, rec.original.title);
|
|
387
453
|
// `rec.original` now holds the latest content, so a later "back to
|
|
388
454
|
// tile" docks the current view (not the kind first promoted). The
|
|
389
455
|
// window owns no tile in the tree, so there is no leaf to update.
|
|
@@ -430,12 +496,29 @@ export class WindowManager {
|
|
|
430
496
|
const tree = this._tree();
|
|
431
497
|
const focused = tree.focusedLeafId;
|
|
432
498
|
if (!focused) return;
|
|
433
|
-
|
|
499
|
+
// Chrome split buttons mirror Alt+H / Alt+V. The freshly created
|
|
500
|
+
// pane is SEEDED with the default HOME content (the taxonomy root)
|
|
501
|
+
// rather than left empty — the never-empty-tile invariant: a tile
|
|
502
|
+
// in the grid always holds content the user can act on, and the
|
|
503
|
+
// main tile is never left blank.
|
|
504
|
+
const newId = tree.split(focused, dir);
|
|
505
|
+
if (newId) this._seedHome(tree, newId);
|
|
434
506
|
this.renderer.render();
|
|
435
507
|
this._persist();
|
|
436
508
|
this._notifyChange();
|
|
437
509
|
}
|
|
438
510
|
|
|
511
|
+
/** Seed a leaf with the default HOME content (the taxonomy root kind).
|
|
512
|
+
* Used to keep the never-empty-tile invariant: the pane freed by a
|
|
513
|
+
* split, or emptied when its last tab floats into a window, is
|
|
514
|
+
* re-homed instead of destroyed or left blank. Embedder-agnostic —
|
|
515
|
+
* the HOME kind comes from the taxonomy, exactly like a fresh
|
|
516
|
+
* desktop's seed leaf. */
|
|
517
|
+
_seedHome(tree, leafId) {
|
|
518
|
+
const seed = this._rootLeaf();
|
|
519
|
+
tree.setLeafContent(leafId, seed.content, seed.title);
|
|
520
|
+
}
|
|
521
|
+
|
|
439
522
|
/** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
|
|
440
523
|
* created sibling — the "open this content in a new split" primitive
|
|
441
524
|
* behind the code-pane split buttons, the per-pane context menu and
|
|
@@ -502,6 +585,29 @@ export class WindowManager {
|
|
|
502
585
|
const leaf = tree.get(focusedId);
|
|
503
586
|
const kind = leaf?.content?.kind;
|
|
504
587
|
|
|
588
|
+
// ══ C20. THE CONTENT'S VETO IS HONOURED HERE, NOT ONLY PAINTED ══
|
|
589
|
+
//
|
|
590
|
+
// `chrome: { close: false }` — or `{ close: { disabled, title } }`,
|
|
591
|
+
// which is the same refusal with a sentence attached — is read at mount
|
|
592
|
+
// and `_vetoStructuralActions` acts on it by disabling the × in the
|
|
593
|
+
// tile's chrome. That is ONE door. This verb has three more: Alt+W
|
|
594
|
+
// (`keymap.js`), the tile's right-click *Close tile* (`shell.js`), and
|
|
595
|
+
// the tab strip's × on a leaf's LAST tab (`_leafTabAction`, which
|
|
596
|
+
// delegates straight to here). All three closed a pane whose own button
|
|
597
|
+
// was greyed out with a tooltip promising it could not be closed.
|
|
598
|
+
//
|
|
599
|
+
// This is the same asymmetry `_floatableLeaf` was written to end for
|
|
600
|
+
// `promote`, resolved the same way and for the same reason: the
|
|
601
|
+
// renderer stays the only thing that knows what was mounted, and the WM
|
|
602
|
+
// stays the only thing that decides whether a verb runs. A veto the
|
|
603
|
+
// content states once holds for every door, or it is decoration.
|
|
604
|
+
//
|
|
605
|
+
// Silent rather than noisy: the control that offers this is already
|
|
606
|
+
// disabled and already carries the explanation, so a keystroke that
|
|
607
|
+
// does nothing is consistent with what the screen says.
|
|
608
|
+
const closeChrome = this.renderer?.leafChrome?.(focusedId)?.close;
|
|
609
|
+
if (closeChrome === false || closeChrome?.disabled === true) return;
|
|
610
|
+
|
|
505
611
|
// Closing a placeholder closes its window — the window's onClose
|
|
506
612
|
// handler restores the leaf, then we close that leaf too.
|
|
507
613
|
if (kind === PLACEHOLDER_KIND) {
|
|
@@ -526,11 +632,28 @@ export class WindowManager {
|
|
|
526
632
|
tree.close(focusedId);
|
|
527
633
|
}
|
|
528
634
|
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
635
|
+
// THE MASTER TILE COMES BACK.
|
|
636
|
+
//
|
|
637
|
+
// Closing the last content tile used to leave the panels occupying the
|
|
638
|
+
// whole WM, on the reasoning that `openInPrimary` would spawn a new leaf
|
|
639
|
+
// when something needed one. That is true and it is not enough: the
|
|
640
|
+
// content area is also the GROUND floating windows stand on, so an
|
|
641
|
+
// embedder that promotes a tile into a window and then closes the tile
|
|
642
|
+
// it came from is left with a window floating over nothing, and no
|
|
643
|
+
// surface to drop it back onto. There is nowhere to put it and nothing
|
|
644
|
+
// saying what to do next.
|
|
645
|
+
//
|
|
646
|
+
// So the invariant is now the same one `toggleManagedFocused` already
|
|
647
|
+
// keeps for a lone tile: there is ALWAYS a content leaf, and it holds
|
|
648
|
+
// the taxonomy root. `_seedHome` is the same call, so the tile that
|
|
649
|
+
// comes back is the same tile a fresh desktop starts with.
|
|
650
|
+
if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
|
|
651
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
652
|
+
if (spawned) this._seedHome(tree, spawned);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Re-canonicalize so the remaining panels fill the area properly (no
|
|
656
|
+
// leftover wrap from the closed content leaf).
|
|
534
657
|
this._canonicalize(tree, this.desktops.active());
|
|
535
658
|
|
|
536
659
|
this.renderer.render();
|
|
@@ -611,31 +734,256 @@ export class WindowManager {
|
|
|
611
734
|
}
|
|
612
735
|
|
|
613
736
|
// ── Tile <-> Managed window ─────────────────────────────────────
|
|
614
|
-
/**
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
737
|
+
/** Float the focused pane into a managed window.
|
|
738
|
+
*
|
|
739
|
+
* R8. THE WHOLE PANE, not its active tab. This used to float one tab and
|
|
740
|
+
* leave the rest behind, which made "float this pane as a window" a
|
|
741
|
+
* different verb from the one its own tooltip named: a pane with three
|
|
742
|
+
* tables in it became a window holding one and a pane holding two, and
|
|
743
|
+
* nothing on screen said which of the three you were going to get. The
|
|
744
|
+
* product owner's words are the whole specification — *"to window includes
|
|
745
|
+
* the tab-strip"* — so the tabs travel with the pane and the strip is
|
|
746
|
+
* rendered INSIDE the window.
|
|
747
|
+
*
|
|
748
|
+
* Floating ONE tab is still available and is still wanted; it moved to
|
|
749
|
+
* where it was always meant to be, which is the right-click menu on the
|
|
750
|
+
* tab itself (R9, `floatTabAsWindow`). A verb that acts on one tab belongs
|
|
751
|
+
* on that tab, not on the pane's chrome.
|
|
752
|
+
*
|
|
753
|
+
* Never-empty-tile invariant, unchanged: the emptied pane is RE-SEEDED
|
|
754
|
+
* with the default HOME content rather than destroyed, so the grid never
|
|
755
|
+
* ends up with a missing or blank main tile. */
|
|
618
756
|
toggleManagedFocused() {
|
|
619
757
|
const tree = this._tree();
|
|
620
758
|
const focused = tree.focused();
|
|
621
|
-
if (!focused
|
|
622
|
-
|
|
623
|
-
|
|
759
|
+
if (!focused) return null;
|
|
760
|
+
return this.floatPane(focused.id);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** R8. Float a pane — every tab, with the strip — into a managed window.
|
|
764
|
+
* Returns the window id, or null when the leaf is not something that can
|
|
765
|
+
* be floated. */
|
|
766
|
+
floatPane(leafId) {
|
|
767
|
+
const leaf = this._floatableLeaf(leafId);
|
|
768
|
+
if (!leaf) return null;
|
|
769
|
+
const tabs = _leafTabSpecs(leaf);
|
|
770
|
+
const active = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
771
|
+
return this._promote(leafId, tabs, active, { wholePane: true });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** R9. Float ONE tab of a pane into a managed window, leaving its siblings
|
|
775
|
+
* where they are — which is exactly what `toggleManagedFocused` did before
|
|
776
|
+
* R8, so the behaviour survives, it just moved to the gesture that names
|
|
777
|
+
* it. The tab's right-click menu is the only caller. */
|
|
778
|
+
floatTabAsWindow(leafId, idx) {
|
|
779
|
+
const leaf = this._floatableLeaf(leafId);
|
|
780
|
+
if (!leaf) return null;
|
|
781
|
+
const tabs = _leafTabSpecs(leaf);
|
|
782
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return null;
|
|
783
|
+
return this._promote(leafId, [tabs[idx]], 0, { wholePane: false, tabIdx: idx });
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* C33. MOVE ONE TAB INTO ANOTHER TILE — the same verb as `floatTabAsWindow`
|
|
788
|
+
* above with a TILE as the destination instead of a window, which is why it
|
|
789
|
+
* sits beside it.
|
|
790
|
+
*
|
|
791
|
+
* ══ THE ORDER IS LOAD-BEARING ═══════════════════════════════════════
|
|
792
|
+
*
|
|
793
|
+
* Four steps, and three of them are in this order for a reason that a
|
|
794
|
+
* plausible-looking rewrite would destroy:
|
|
795
|
+
*
|
|
796
|
+
* (a) READ THE TAB SPEC FIRST. `fromIdx` is an ARRAY INDEX — the only
|
|
797
|
+
* identity a tile tab has (`tile_renderer._tabKey`) — so it is stale
|
|
798
|
+
* the instant anything splices a tab list. Everything below works
|
|
799
|
+
* from the copy taken here.
|
|
800
|
+
*
|
|
801
|
+
* (b) SPLIT BEFORE REMOVING. When the destination IS the source pane —
|
|
802
|
+
* "tear this tab off into a split beside its siblings" — removing
|
|
803
|
+
* first can empty that pane and send it through `_seedHome`, so the
|
|
804
|
+
* split would then be splitting a freshly seeded ground rather than
|
|
805
|
+
* the pane the preview drew. Splitting first cannot go wrong in the
|
|
806
|
+
* other direction: `tree.split` never touches tabs.
|
|
807
|
+
*
|
|
808
|
+
* (c) THE MOVE ITSELF IS ONE TREE CALL for `tab` — `moveTabToLeaf`, which
|
|
809
|
+
* exists so the tab cannot be in flight between two mutations — and
|
|
810
|
+
* remove-then-`setLeafContent` for `fill`/`split`, where the
|
|
811
|
+
* destination is ground or brand new and REPLACING is the point.
|
|
812
|
+
*
|
|
813
|
+
* (d) RE-SEED AND MERGE, exactly as `_promote` does when the last tab
|
|
814
|
+
* leaves a pane (`_seedHome` then `_mergeStartTiles`). A pane is
|
|
815
|
+
* never left blank, and two grounds never end up side by side with a
|
|
816
|
+
* splitter between them for no reason.
|
|
817
|
+
*
|
|
818
|
+
* ══ `wm:tab-moved` IS EMITTED BEFORE THE REPAINT ════════════════════
|
|
819
|
+
*
|
|
820
|
+
* An embedder that keys live content by leaf id — the Tables grid registry
|
|
821
|
+
* does, on `(leaf, table)`, because a DOM element exists in exactly one
|
|
822
|
+
* place — has to re-key BEFORE the render mounts the destination, or the
|
|
823
|
+
* destination misses its entry, builds a second grid, and the source tile's
|
|
824
|
+
* deferred teardown destroys the first one along with everything typed into
|
|
825
|
+
* it and not yet committed. Emitting after the render would lose that race
|
|
826
|
+
* silently, which is the failure this repository keeps recording. The tree
|
|
827
|
+
* is already correct at this point; only the DOM is stale.
|
|
828
|
+
*
|
|
829
|
+
* ══ WHAT THIS DELIBERATELY DOES NOT DO ══════════════════════════════
|
|
830
|
+
*
|
|
831
|
+
* A tab is not dragged OUT OF A FLOATING WINDOW's strip, and a tab dropped
|
|
832
|
+
* on empty space does not become a window. Both are refused by omission
|
|
833
|
+
* rather than half-built, and both have a reason. A window's tabs live in
|
|
834
|
+
* `_windowToLeaf`'s record and not in the tree, so their source policy is
|
|
835
|
+
* `_windowTabAction`'s and not this function's. And "dropped on nothing" in
|
|
836
|
+
* HTML5 drag-and-drop is `dragend` with no `drop` — which is also exactly
|
|
837
|
+
* what pressing Escape produces, so floating a window on it would float one
|
|
838
|
+
* every time a user changed their mind. Crossing DESKTOPS is out for a
|
|
839
|
+
* third reason: only the active desktop is rendered, so there is no target
|
|
840
|
+
* to hit.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} fromLeafId
|
|
843
|
+
* @param {number} fromIdx
|
|
844
|
+
* @param {{leafId: string, mode?: 'tab'|'fill'|'split', dir?: 'h'|'v',
|
|
845
|
+
* before?: boolean, toIdx?: number}} target a `tabDropProbe`
|
|
846
|
+
* answer, translated by the renderer
|
|
847
|
+
* @returns {string|null} the leaf the tab landed in, or null if refused
|
|
848
|
+
*/
|
|
849
|
+
moveTabInto(fromLeafId, fromIdx, target = {}) {
|
|
850
|
+
const tree = this._tree();
|
|
851
|
+
const from = tree.get(fromLeafId);
|
|
852
|
+
if (!from || from.kind !== 'leaf') return null;
|
|
853
|
+
const tabs = Array.isArray(from.tabs) ? from.tabs : [];
|
|
854
|
+
if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
|
|
855
|
+
let destId = target?.leafId || null;
|
|
856
|
+
const dest = destId ? tree.get(destId) : null;
|
|
857
|
+
if (!dest || dest.kind !== 'leaf') return null;
|
|
858
|
+
if (String(dest.content?.kind || '').startsWith('panel:')) return null;
|
|
859
|
+
const mode = (target.mode === 'fill' || target.mode === 'split')
|
|
860
|
+
? target.mode : 'tab';
|
|
861
|
+
// The two no-ops `tabDropProbe` already refuses, refused a second time
|
|
862
|
+
// here because this is a PUBLIC verb and a keyboard or palette caller
|
|
863
|
+
// never went through the probe: a tab dropped into the pane it already
|
|
864
|
+
// lives in, and a single-tab pane split against itself.
|
|
865
|
+
if (destId === fromLeafId && mode !== 'split') return null;
|
|
866
|
+
if (destId === fromLeafId && tabs.length <= 1) return null;
|
|
867
|
+
|
|
868
|
+
// (a) the spec, copied before any mutation makes the index a lie.
|
|
869
|
+
const src = tabs[fromIdx];
|
|
870
|
+
const spec = { kind: src.kind, props: { ...(src.props || {}) },
|
|
871
|
+
title: src.title || src.kind || '' };
|
|
872
|
+
|
|
873
|
+
// (b) the split, before the removal.
|
|
874
|
+
if (mode === 'split') {
|
|
875
|
+
const newId = tree.split(destId, target.dir === 'v' ? 'v' : 'h');
|
|
876
|
+
if (!newId) return null;
|
|
877
|
+
// THE PREVIEW PROMISED HALF OF THAT TILE, and `TileTree.split` only
|
|
878
|
+
// delivers a half when it has to WRAP — into a row that already
|
|
879
|
+
// runs this way it INSERTS, handing the newcomer the average of the
|
|
880
|
+
// existing sizes. The same two corrections `_onManagedWindowClosed`
|
|
881
|
+
// applies to a window drop (C15), for the same reason and in the
|
|
882
|
+
// same order.
|
|
883
|
+
_halveInto(tree, destId, newId);
|
|
884
|
+
if (target.before) _swapSiblings(tree, destId, newId);
|
|
885
|
+
destId = newId;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// (c) the move.
|
|
889
|
+
if (mode === 'tab') {
|
|
890
|
+
const moved = tree.moveTabToLeaf(fromLeafId, fromIdx, destId,
|
|
891
|
+
Number.isInteger(target.toIdx) ? target.toIdx : -1);
|
|
892
|
+
if (!moved?.ok) return null;
|
|
893
|
+
} else {
|
|
894
|
+
tree.removeLeafTab(fromLeafId, fromIdx);
|
|
895
|
+
tree.setLeafContent(destId, { kind: spec.kind, props: spec.props }, spec.title);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// (d) the source pane, if the last tab just left it.
|
|
899
|
+
if (!((tree.get(fromLeafId)?.tabs || []).length)) {
|
|
900
|
+
this._seedHome(tree, fromLeafId);
|
|
901
|
+
this._mergeStartTiles(tree, fromLeafId);
|
|
902
|
+
}
|
|
903
|
+
this._canonicalize(tree, this.desktops.active());
|
|
904
|
+
|
|
905
|
+
// BEFORE THE REPAINT. See the block comment above — this is the moment
|
|
906
|
+
// the tree is right and the DOM has not moved yet, and it is the only
|
|
907
|
+
// moment an embedder can re-key content it holds by leaf id.
|
|
908
|
+
try {
|
|
909
|
+
this.eventBus?.emit?.('wm:tab-moved',
|
|
910
|
+
{ fromLeafId, toLeafId: destId, tab: spec, mode });
|
|
911
|
+
} catch (err) { console.warn('[wm] tab-moved emit failed', err); }
|
|
912
|
+
|
|
913
|
+
if (tree.get(destId)) tree.focus(destId);
|
|
914
|
+
this.renderer.render();
|
|
915
|
+
this._persist();
|
|
916
|
+
this._notifyChange('tab-moved');
|
|
917
|
+
return destId;
|
|
918
|
+
}
|
|
624
919
|
|
|
625
|
-
|
|
920
|
+
/** The guards both float verbs share. A panel tile is chrome, not content;
|
|
921
|
+
* a window placeholder is already a window; an empty tile has nothing to
|
|
922
|
+
* carry — and, since C20 was extended, content that declared itself
|
|
923
|
+
* unfloatable is not floated by ANY door.
|
|
924
|
+
*
|
|
925
|
+
* THE LAST ONE IS WHY THIS FUNCTION IS THE RIGHT PLACE. C20 let a content
|
|
926
|
+
* factory return `chrome: { promote: false }`, and the renderer honoured
|
|
927
|
+
* it by not PAINTING the float button. That is one door of four: the
|
|
928
|
+
* tile's right-click menu has offered "Float this pane as a window" all
|
|
929
|
+
* along (`shell.js`'s `_tileContextMenu`, whose only guard is
|
|
930
|
+
* `isPanel || !leaf.content`), the chrome pull-down asks for `promote`,
|
|
931
|
+
* and so now does the chrome's double-click. Each of them arrives here.
|
|
932
|
+
*
|
|
933
|
+
* The case it protects is an embedder's MASTER tile: the ground that
|
|
934
|
+
* floating windows stand on. Floating it promotes the ground into a
|
|
935
|
+
* window, which force-closes every window standing on it and re-seeds the
|
|
936
|
+
* pane WITHOUT the props that made it a ground — reproduced end to end
|
|
937
|
+
* before this guard existed. A veto the content states once should hold
|
|
938
|
+
* for every gesture, not only the one the renderer draws. */
|
|
939
|
+
_floatableLeaf(leafId) {
|
|
940
|
+
const leaf = leafId ? this._tree().get(leafId) : null;
|
|
941
|
+
if (!leaf || leaf.kind !== 'leaf' || !leaf.content) return null;
|
|
942
|
+
if (PANEL_KINDS.has(leaf.content.kind)) return null;
|
|
943
|
+
if (leaf.content.kind === PLACEHOLDER_KIND) return null;
|
|
944
|
+
// `=== false` rather than falsy: content that says nothing about
|
|
945
|
+
// `promote` stays floatable, which is what every existing embedder
|
|
946
|
+
// relies on. Only an explicit refusal refuses.
|
|
947
|
+
if (this.renderer?.leafChrome?.(leaf.id)?.promote === false) return null;
|
|
948
|
+
return leaf;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The promote itself: build the window, mount the active tab in it, and
|
|
953
|
+
* take the tabs out of the tree.
|
|
954
|
+
*
|
|
955
|
+
* @param {string} leafId the pane the tabs are coming out of
|
|
956
|
+
* @param {object[]} tabs `{kind, props, title}`, in order
|
|
957
|
+
* @param {number} activeTabIdx which of them the window shows first
|
|
958
|
+
* @param {{wholePane: boolean, tabIdx?: number}} opts
|
|
959
|
+
*/
|
|
960
|
+
_promote(leafId, tabs, activeTabIdx, { wholePane, tabIdx = -1 }) {
|
|
961
|
+
const tree = this._tree();
|
|
962
|
+
const leaf = tree.get(leafId);
|
|
626
963
|
const desktopIdx = this.desktops.activeIdx;
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
title: focused.title,
|
|
631
|
-
};
|
|
964
|
+
const tabCount = Array.isArray(leaf.tabs) ? leaf.tabs.length : 1;
|
|
965
|
+
const active = Math.max(0, Math.min(tabs.length - 1, activeTabIdx || 0));
|
|
966
|
+
const original = { ...tabs[active], props: { ...(tabs[active].props || {}) } };
|
|
632
967
|
|
|
633
968
|
const contentEl = document.createElement('div');
|
|
634
969
|
contentEl.className = 'twm-window-content';
|
|
635
970
|
contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
|
|
971
|
+
// R8. THE STRIP IS INSIDE THE WINDOW. A pane's tabs and a pane's body
|
|
972
|
+
// are one thing — *"to window includes the tab-strip"* — so the window
|
|
973
|
+
// gets both, in the order the `top` layout draws them, and the content
|
|
974
|
+
// mounts into the BODY rather than into the window's content element.
|
|
975
|
+
// Everything that used to write straight into `contentEl` (see
|
|
976
|
+
// `openInWindow`) has to write into the body now, or it takes the strip
|
|
977
|
+
// with it the first time the window's content is replaced.
|
|
978
|
+
const tabBarEl = document.createElement('div');
|
|
979
|
+
tabBarEl.className = 'twm-window-tabbar';
|
|
980
|
+
const bodyEl = document.createElement('div');
|
|
981
|
+
bodyEl.className = 'twm-window-body';
|
|
982
|
+
bodyEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0;';
|
|
983
|
+
contentEl.append(tabBarEl, bodyEl);
|
|
636
984
|
|
|
637
985
|
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
638
|
-
const mountInfo = this.content.mount(original.kind,
|
|
986
|
+
const mountInfo = this.content.mount(original.kind, bodyEl, original.props,
|
|
639
987
|
{ ...this.ctx, wm: this, windowId: winId });
|
|
640
988
|
const win = new ManagedWindow({
|
|
641
989
|
id: winId,
|
|
@@ -646,68 +994,1642 @@ export class WindowManager {
|
|
|
646
994
|
canMaximize: true,
|
|
647
995
|
canResize: true,
|
|
648
996
|
modal: false,
|
|
649
|
-
|
|
997
|
+
// C15. Dropping a promoted window on a tile PUTS IT BACK — as that
|
|
998
|
+
// tile's content, as a split of it, or as one of its tabs. Off
|
|
999
|
+
// unless the embedder asked, because it changes what a drag to an
|
|
1000
|
+
// edge means.
|
|
1001
|
+
snap: this.snapPromotion,
|
|
1002
|
+
snapController: this.snapPromotion ? this._snapController() : null,
|
|
1003
|
+
// R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
|
|
1004
|
+
// to one (C21) cannot be dragged a single pixel outside it, so
|
|
1005
|
+
// "drag a window from one tile to another" — the gesture all three
|
|
1006
|
+
// drop behaviours are built on — was not merely awkward, it was
|
|
1007
|
+
// invisible. For the length of a drag the window is re-parented
|
|
1008
|
+
// here, to the root every tile is inside; on release it goes back
|
|
1009
|
+
// into a pane, either the one it was dropped on or the one it came
|
|
1010
|
+
// from. Resolved per drag: the root outlives any tile, and a tile
|
|
1011
|
+
// grabbed once does not survive its own repaint.
|
|
1012
|
+
dragHost: () => this.rootEl,
|
|
1013
|
+
dragBounds: () => this._tileBounds(),
|
|
1014
|
+
// R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
|
|
1015
|
+
// tree; the useful thing to do with it is put it back, and filling
|
|
1016
|
+
// the screen with it is the one gesture that makes putting it back
|
|
1017
|
+
// harder. So the maximize button docks — and the separate demote
|
|
1018
|
+
// button the WM used to inject beside it is gone, because two
|
|
1019
|
+
// buttons for one verb is how you get a chrome nobody reads.
|
|
1020
|
+
onMaximize: () => this.bringBackWindow(winId),
|
|
1021
|
+
maximizeIcon: 'close_fullscreen',
|
|
1022
|
+
maximizeTitle: 'Back to tile',
|
|
1023
|
+
onClose: () => this._onManagedWindowClosed(winId, null),
|
|
650
1024
|
});
|
|
651
1025
|
|
|
652
1026
|
this._windowToLeaf.set(winId, {
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
1027
|
+
// The floated tabs now live in the window, not the tree. leafId
|
|
1028
|
+
// is null so "back to tile" re-docks into the desktop's primary
|
|
1029
|
+
// tile (see _onManagedWindowClosed) — the source tile itself
|
|
1030
|
+
// survives (re-seeded with HOME when it was emptied).
|
|
656
1031
|
leafId: null, desktopIdx, original, mountInfo, window: win,
|
|
657
|
-
contentEl,
|
|
1032
|
+
contentEl, bodyEl, tabBarEl,
|
|
1033
|
+
// R8. THE PANE'S TABS TRAVEL WITH IT, and this is where they live
|
|
1034
|
+
// while the window is open. `original` still mirrors the ACTIVE one
|
|
1035
|
+
// so every existing reader of the record — `openInWindow`, each of
|
|
1036
|
+
// `_onManagedWindowClosed`'s docks — keeps working unchanged; the
|
|
1037
|
+
// list beside it is what makes a dock restore ALL of them.
|
|
1038
|
+
tabs, activeTabIdx: active, strip: null,
|
|
1039
|
+
// ══ C21. WRITTEN HERE, BEFORE THE TREE IS TOUCHED ═══════════
|
|
1040
|
+
//
|
|
1041
|
+
// `homeLeafId` is the pane this window stands on, and it used to be
|
|
1042
|
+
// assigned at the BOTTOM of this function — after `_seedHome`,
|
|
1043
|
+
// after `_mergeStartTiles`, after the repaint. That ordering
|
|
1044
|
+
// destroyed a tile per promotion, and it looked like a window bug
|
|
1045
|
+
// because a window is what the user had just moved.
|
|
1046
|
+
//
|
|
1047
|
+
// `_mergeStartTiles` (below) refuses to merge a start tile that has
|
|
1048
|
+
// a window standing on it, and `_paneHoldsWindows` answers that
|
|
1049
|
+
// question two ways: THIS FIELD, and a DOM probe for a
|
|
1050
|
+
// `.twm-managed-window` inside the leaf. At the old assignment point
|
|
1051
|
+
// neither could be true yet — the field was unwritten and the window
|
|
1052
|
+
// had not been `moveTo`'d into the pane — so the pane that was one
|
|
1053
|
+
// line away from becoming this window's ground answered *nothing
|
|
1054
|
+
// floats here* and was merged into its neighbour.
|
|
1055
|
+
//
|
|
1056
|
+
// Three panes floated one after another ended as ONE pane: the
|
|
1057
|
+
// first promotion left a start tile, the second merged its own
|
|
1058
|
+
// freshly-seeded tile away, and so did the third. Every window
|
|
1059
|
+
// after the first was then left with a `homeLeafId` naming a leaf
|
|
1060
|
+
// the tree no longer had — so the `moveTo` below was skipped and the
|
|
1061
|
+
// window never became contained, `_rehomeContainedWindows` found no
|
|
1062
|
+
// element to re-home it into, and `bringBackWindow` fell through to
|
|
1063
|
+
// the PRIMARY tile and docked as a tab onto the ground ANOTHER
|
|
1064
|
+
// window was standing on. That is the whole of the reported
|
|
1065
|
+
// *"expand one to a tile > influences others or even tiles lost"*.
|
|
1066
|
+
//
|
|
1067
|
+
// Writing it here is the smallest fix that closes all of it: a
|
|
1068
|
+
// guard that already existed starts being able to see the window it
|
|
1069
|
+
// was written to protect. The `moveTo` stays at the bottom, because
|
|
1070
|
+
// it needs the wrap the repaint rebuilds.
|
|
1071
|
+
homeLeafId: this.promoteInPlace ? leafId : null,
|
|
658
1072
|
// Set to true by bringBackWindow so the close path knows to
|
|
659
1073
|
// restore the content instead of destroying it.
|
|
660
1074
|
_demoting: false,
|
|
661
1075
|
});
|
|
1076
|
+
this._syncWindowTabs(winId);
|
|
1077
|
+
|
|
1078
|
+
// R9. ONE TAB out of a multi-tab pane: the siblings stay put. R8: the
|
|
1079
|
+
// pane is emptied and re-seeded, because the whole of it left.
|
|
1080
|
+
if (!wholePane && tabCount > 1) tree.removeLeafTab(leafId, tabIdx);
|
|
1081
|
+
else this._seedHome(tree, leafId);
|
|
662
1082
|
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
1083
|
+
// R10. The pane that just emptied is a START TILE now, and so may be
|
|
1084
|
+
// the pane beside it. Two of them side by side are one surface with a
|
|
1085
|
+
// splitter through it for no reason — see `_mergeStartTiles`, which is
|
|
1086
|
+
// where the rest of that argument lives.
|
|
1087
|
+
this._mergeStartTiles(tree, leafId);
|
|
1088
|
+
|
|
1089
|
+
tree.focus(leafId);
|
|
669
1090
|
this._canonicalize(tree, this.desktops.active());
|
|
670
1091
|
|
|
671
1092
|
this.renderer.render();
|
|
1093
|
+
|
|
1094
|
+
// CONFINED TO THE PANE IT CAME FROM, when the embedder asked for it.
|
|
1095
|
+
//
|
|
1096
|
+
// Done AFTER the render and BEFORE `show()`, and both halves of that
|
|
1097
|
+
// matter. After, because promoting re-seeds the source leaf, which
|
|
1098
|
+
// changes its cache key and REBUILDS its wrap — the element grabbed
|
|
1099
|
+
// before the render is destroyed by it. Before, because `moveTo` on a
|
|
1100
|
+
// window that has not been built yet simply records the container
|
|
1101
|
+
// (`managed_window.js` returns early on a null element), so `show()`
|
|
1102
|
+
// mounts straight into the pane instead of appearing at the viewport
|
|
1103
|
+
// and jumping.
|
|
1104
|
+
//
|
|
1105
|
+
// The LEAF WRAP, not the `.twm-slot` around it: slots are recreated on
|
|
1106
|
+
// every render (`tile_renderer.js` clears the root and rebuilds them)
|
|
1107
|
+
// while leaf wraps are cached and re-parented, so a window contained to
|
|
1108
|
+
// a slot would be orphaned the first time anything repainted. The wrap
|
|
1109
|
+
// fills the slot, so the two are the same box.
|
|
1110
|
+
if (this.promoteInPlace) {
|
|
1111
|
+
// `homeLeafId` was written with the record, above, and the argument
|
|
1112
|
+
// for that is there. It is what `_rehomeContainedWindows` re-parents
|
|
1113
|
+
// against after every repaint — without it the SECOND promotion out
|
|
1114
|
+
// of a two-tab pane deletes the first one's window: the leaf's wrap
|
|
1115
|
+
// is rebuilt when its tab set changes, and the first window was
|
|
1116
|
+
// parented into the wrap that the rebuild threw away.
|
|
1117
|
+
const paneEl = this.renderer.leafEl(leafId);
|
|
1118
|
+
if (paneEl) win.moveTo(paneEl);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
672
1121
|
win.show();
|
|
673
1122
|
this._decorateManagedWindow(win, winId);
|
|
674
1123
|
this._persist();
|
|
675
1124
|
this._notifyChange('window-promoted');
|
|
1125
|
+
return winId;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// ══ R8. The tab strip inside a floated pane ═══════════════════════
|
|
1129
|
+
/**
|
|
1130
|
+
* Draw (or hide) a window's tab strip, and keep its title honest.
|
|
1131
|
+
*
|
|
1132
|
+
* Hidden below two tabs, exactly as a pane's strip is
|
|
1133
|
+
* (`tile_renderer._renderTabBar`): the common case is one tab, and a strip
|
|
1134
|
+
* naming the one thing you are already looking at is a line of chrome
|
|
1135
|
+
* saying nothing. Building the strip lazily also means a window promoted
|
|
1136
|
+
* out of a single-tab pane costs no `NotebookTabBar` at all.
|
|
1137
|
+
*/
|
|
1138
|
+
_syncWindowTabs(winId) {
|
|
1139
|
+
const rec = this._windowToLeaf.get(winId);
|
|
1140
|
+
if (!rec) return;
|
|
1141
|
+
const tabs = rec.tabs || [];
|
|
1142
|
+
rec.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
|
|
1143
|
+
if (tabs.length <= 1) {
|
|
1144
|
+
try { rec.strip?.dispose(); } catch { /* already gone */ }
|
|
1145
|
+
rec.strip = null;
|
|
1146
|
+
rec.tabBarEl?.classList.add('twm-window-tabbar--hidden');
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
rec.tabBarEl?.classList.remove('twm-window-tabbar--hidden');
|
|
1150
|
+
if (!rec.strip) {
|
|
1151
|
+
rec.strip = createTabStrip({
|
|
1152
|
+
hostEl: rec.tabBarEl,
|
|
1153
|
+
taxonomy: this.taxonomy,
|
|
1154
|
+
onAction: (action, data) => this._windowTabAction(winId, action, data),
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
rec.strip.update(tabs, rec.activeTabIdx);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** The window strip's half of `_leafTabAction` — the same four verbs
|
|
1161
|
+
* against the window record instead of against the tree. */
|
|
1162
|
+
_windowTabAction(winId, action, data = {}) {
|
|
1163
|
+
const rec = this._windowToLeaf.get(winId);
|
|
1164
|
+
if (!rec) return;
|
|
1165
|
+
const tabs = rec.tabs || [];
|
|
1166
|
+
if (action === 'switch') {
|
|
1167
|
+
this.showWindowTab(winId, data.idx);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (action === 'close') {
|
|
1171
|
+
if (data.idx < 0 || data.idx >= tabs.length) return;
|
|
1172
|
+
// THE LAST TAB CLOSES THE WINDOW, which is what closing the last
|
|
1173
|
+
// tab of a pane does to the pane (`_leafTabAction`). A window with
|
|
1174
|
+
// no content in it is a title bar over nothing.
|
|
1175
|
+
if (tabs.length <= 1) {
|
|
1176
|
+
try { rec.window.close({ force: true }); } catch { /* gone */ }
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
tabs.splice(data.idx, 1);
|
|
1180
|
+
if (data.idx < rec.activeTabIdx) rec.activeTabIdx -= 1;
|
|
1181
|
+
else if (data.idx === rec.activeTabIdx) {
|
|
1182
|
+
rec.activeTabIdx = Math.max(0, data.idx - 1);
|
|
1183
|
+
this._mountWindowTab(winId);
|
|
1184
|
+
}
|
|
1185
|
+
this._syncWindowTabs(winId);
|
|
1186
|
+
this._notifyChange('window-tab-close');
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (action === 'move') {
|
|
1190
|
+
const { from, to } = data;
|
|
1191
|
+
if (from == null || to == null) return;
|
|
1192
|
+
if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return;
|
|
1193
|
+
const moved = tabs.splice(from, 1)[0];
|
|
1194
|
+
tabs.splice(to, 0, moved);
|
|
1195
|
+
// The same index arithmetic `TileTree.moveLeafTab` does, for the
|
|
1196
|
+
// same reason: the tab that was showing must still be showing.
|
|
1197
|
+
if (rec.activeTabIdx === from) rec.activeTabIdx = to;
|
|
1198
|
+
else if (from < rec.activeTabIdx && to >= rec.activeTabIdx) rec.activeTabIdx -= 1;
|
|
1199
|
+
else if (from > rec.activeTabIdx && to <= rec.activeTabIdx) rec.activeTabIdx += 1;
|
|
1200
|
+
this._syncWindowTabs(winId);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (action === 'menu') this._showWindowTabContextMenu(winId, data.idx, data.x, data.y);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Show one of a floated pane's tabs. Public because a window is the only
|
|
1207
|
+
* place this list exists — nothing else can reach it. */
|
|
1208
|
+
showWindowTab(winId, idx) {
|
|
1209
|
+
const rec = this._windowToLeaf.get(winId);
|
|
1210
|
+
if (!rec) return false;
|
|
1211
|
+
const tabs = rec.tabs || [];
|
|
1212
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return false;
|
|
1213
|
+
if (idx === rec.activeTabIdx) return true;
|
|
1214
|
+
rec.activeTabIdx = idx;
|
|
1215
|
+
this._mountWindowTab(winId);
|
|
1216
|
+
this._syncWindowTabs(winId);
|
|
1217
|
+
this._persist();
|
|
1218
|
+
this._notifyChange('window-tab-switch');
|
|
1219
|
+
return true;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Tear the current mount down and mount the active tab in its place.
|
|
1223
|
+
* `rec.original` follows, so a later dock puts back what is on screen. */
|
|
1224
|
+
_mountWindowTab(winId) {
|
|
1225
|
+
const rec = this._windowToLeaf.get(winId);
|
|
1226
|
+
if (!rec) return;
|
|
1227
|
+
const tab = (rec.tabs || [])[rec.activeTabIdx];
|
|
1228
|
+
if (!tab) return;
|
|
1229
|
+
try { rec.mountInfo?.destroy?.(); } catch { /* factory threw on the way out */ }
|
|
1230
|
+
rec.bodyEl.innerHTML = '';
|
|
1231
|
+
rec.mountInfo = this.content.mount(tab.kind, rec.bodyEl, tab.props || {},
|
|
1232
|
+
{ ...this.ctx, wm: this, windowId: winId });
|
|
1233
|
+
rec.original = { kind: tab.kind, props: { ...(tab.props || {}) },
|
|
1234
|
+
title: rec.mountInfo?.title || tab.title || tab.kind };
|
|
1235
|
+
this._setWindowTitle(rec, rec.original.title);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** The window's title, in both places it is kept. */
|
|
1239
|
+
_setWindowTitle(rec, title) {
|
|
1240
|
+
try {
|
|
1241
|
+
const titleEl = rec.window?.element?.querySelector('.twm-managed-window__title');
|
|
1242
|
+
if (titleEl) titleEl.textContent = title;
|
|
1243
|
+
if (rec.window) rec.window.title = title;
|
|
1244
|
+
} catch { /* the window is already torn down */ }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The window strip's context menu. Deliberately the close verbs and
|
|
1248
|
+
* nothing else: a tab in a window is already out of the tree, so
|
|
1249
|
+
* "open in a window" — the verb R9 adds to a PANE's tab menu — has
|
|
1250
|
+
* nowhere further to go. */
|
|
1251
|
+
_showWindowTabContextMenu(winId, idx, x, y) {
|
|
1252
|
+
const rec = this._windowToLeaf.get(winId);
|
|
1253
|
+
const tabs = rec?.tabs || [];
|
|
1254
|
+
if (!tabs.length) return;
|
|
1255
|
+
const items = [{ label: 'Close tab', icon: 'close', action: 'close' }];
|
|
1256
|
+
if (tabs.length > 1) {
|
|
1257
|
+
items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
|
|
1258
|
+
}
|
|
1259
|
+
showContextMenu(x, y, items, (action) => {
|
|
1260
|
+
const live = this._windowToLeaf.get(winId);
|
|
1261
|
+
if (!live) return;
|
|
1262
|
+
if (action === 'close') this._windowTabAction(winId, 'close', { idx });
|
|
1263
|
+
else if (action === 'close-others') {
|
|
1264
|
+
const keep = live.tabs[idx];
|
|
1265
|
+
if (!keep) return;
|
|
1266
|
+
// RE-MOUNT ONLY IF THE KEPT TAB IS NOT THE ONE ON SCREEN.
|
|
1267
|
+
// Mounting is destroy-then-build, and a content factory that
|
|
1268
|
+
// rebuilds is a factory that can lose what is staged in it —
|
|
1269
|
+
// "close the other tabs" must not be a way to discard the edits
|
|
1270
|
+
// in the tab you kept.
|
|
1271
|
+
const remount = idx !== live.activeTabIdx;
|
|
1272
|
+
live.tabs = [keep];
|
|
1273
|
+
live.activeTabIdx = 0;
|
|
1274
|
+
if (remount) this._mountWindowTab(winId);
|
|
1275
|
+
this._syncWindowTabs(winId);
|
|
1276
|
+
this._notifyChange('window-tab-close-others');
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Re-parent every pane-contained window into its pane's CURRENT wrap.
|
|
1283
|
+
*
|
|
1284
|
+
* Called after each repaint. A leaf's wrap is cached per (kind, props, tab
|
|
1285
|
+
* fingerprint) and rebuilt when any of those change, so a window parented
|
|
1286
|
+
* into it is thrown away with the old wrap — silently, because nothing
|
|
1287
|
+
* throws and the window object is still perfectly alive.
|
|
1288
|
+
*
|
|
1289
|
+
* `moveTo` returns false when the container has not changed, so this is a
|
|
1290
|
+
* no-op on every repaint that did not rebuild the pane in question.
|
|
1291
|
+
*/
|
|
1292
|
+
_rehomeContainedWindows() {
|
|
1293
|
+
if (!this.promoteInPlace) return;
|
|
1294
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
1295
|
+
if (!rec.homeLeafId || !rec.window) continue;
|
|
1296
|
+
// R1. NOT WHILE IT IS BEING DRAGGED OUT. A window mid-escape is
|
|
1297
|
+
// parented to the root deliberately and is following the pointer in
|
|
1298
|
+
// the root's coordinates; re-homing it into its pane here would clip
|
|
1299
|
+
// it, re-clamp it, and leave the rest of the drag computing deltas
|
|
1300
|
+
// against a frame that moved underneath it. The escape puts it back
|
|
1301
|
+
// itself on release, and adopts the current wrap when it does.
|
|
1302
|
+
if (rec.window.dragOrigin) continue;
|
|
1303
|
+
// R13, NOW A GUARD RATHER THAN A PATH. NOR WHILE IT IS MAXIMISED
|
|
1304
|
+
// ONTO THE DRAG HOST. A window filling the layer is parented to the
|
|
1305
|
+
// ROOT of necessity — `.twm-leaf` is `overflow: hidden`, so a
|
|
1306
|
+
// window bigger than one tile cannot be a child of one — and
|
|
1307
|
+
// `moveTo` re-clamps into the new container without converting, so
|
|
1308
|
+
// re-homing it here would shrink it back into the pane on the first
|
|
1309
|
+
// repaint after the gesture.
|
|
1310
|
+
//
|
|
1311
|
+
// WHICH ROUTE STILL REACHES IT, honestly stated, because a guard
|
|
1312
|
+
// whose route nobody can name is dead code wearing a comment. R14
|
|
1313
|
+
// removed the one this was WRITTEN for: the aero-snap top edge used
|
|
1314
|
+
// to maximise onto the layer and now docks, so no gesture in this
|
|
1315
|
+
// layer leaves a window maximised on the root any more. What
|
|
1316
|
+
// remains is the library API — `toggleMaximize({claimable: false})`
|
|
1317
|
+
// is public, documented on `ManagedWindow` as the way past a
|
|
1318
|
+
// consumer's claim, and an embedder that calls it on an adopted
|
|
1319
|
+
// window sitting on the drag host produces exactly this state. It
|
|
1320
|
+
// costs one comparison to keep and a silently shrinking window to
|
|
1321
|
+
// remove, so it stays.
|
|
1322
|
+
//
|
|
1323
|
+
// BOTH CONDITIONS, and neither alone. `isMaximized` by itself would
|
|
1324
|
+
// also skip a window maximised INSIDE its pane — that one still has
|
|
1325
|
+
// to follow its wrap when the wrap is rebuilt, which is the whole
|
|
1326
|
+
// job of this pass, and `snap_zones.test.mjs` asserts it. The
|
|
1327
|
+
// container by itself would skip a window the escape left on the
|
|
1328
|
+
// root because its origin was destroyed mid-drag, which is
|
|
1329
|
+
// precisely the case this pass exists to adopt.
|
|
1330
|
+
if (rec.window.isMaximized && rec.window.container === this.rootEl) continue;
|
|
1331
|
+
|
|
1332
|
+
// ══ C21. A WINDOW BELONGS TO ONE DESKTOP ═══════════════════
|
|
1333
|
+
//
|
|
1334
|
+
// …and is in the document only while that desktop is on screen.
|
|
1335
|
+
// That has always been TRUE and was never STATED: switching
|
|
1336
|
+
// desktops evicts the old tree's leaves from the wrap cache
|
|
1337
|
+
// (`tile_renderer._cleanCache`), which takes the windows standing
|
|
1338
|
+
// on them out of the document as a side effect. Saying it here is
|
|
1339
|
+
// what lets `moveWindowToDesktop` actually move a contained window
|
|
1340
|
+
// — it re-points the record and this pass takes the element off
|
|
1341
|
+
// the page the window has just left. Coming back re-parents it:
|
|
1342
|
+
// the destination's wraps are rebuilt on arrival, so `moveTo` sees
|
|
1343
|
+
// a container it has not seen and appends.
|
|
1344
|
+
if (rec.desktopIdx !== this.desktops.activeIdx) {
|
|
1345
|
+
if (!rec.homeContainer && rec.window.element?.isConnected) {
|
|
1346
|
+
try { rec.window.element.remove(); } catch { /* already out */ }
|
|
1347
|
+
}
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ══ A HOME LEAF THAT HAS LEFT THE TREE IS REPAIRED, NOT SKIPPED ══
|
|
1352
|
+
//
|
|
1353
|
+
// *"tiles must only influence tiles, not windows"* — the product
|
|
1354
|
+
// owner, 2026-09-01. A tile can be destroyed by four gestures
|
|
1355
|
+
// (`closeFocused`, the tab strip's × on a last tab,
|
|
1356
|
+
// `moveFocusedToDesktop`, and `_mergeStartTiles`) and NONE of them
|
|
1357
|
+
// asked what was standing on it. `_paneHoldsWindows` — the guard
|
|
1358
|
+
// written for exactly that question — is consulted from one place
|
|
1359
|
+
// in the whole file, and no close path is it.
|
|
1360
|
+
//
|
|
1361
|
+
// What happened when a pane with a window on it was closed:
|
|
1362
|
+
// `_cleanCache` removed the wrap, and the window's element with it,
|
|
1363
|
+
// twenty-three lines BEFORE this pass runs. The window was left
|
|
1364
|
+
// alive with `isVisible: true` and `isMinimized: false` — so it was
|
|
1365
|
+
// on no surface AND in no taskbar (Tables' strip lists the
|
|
1366
|
+
// minimised), holding a mounted grid and its staged edits, with no
|
|
1367
|
+
// chrome to press and no route back. Reproduced end to end.
|
|
1368
|
+
//
|
|
1369
|
+
// Repairing it HERE rather than at each of the four call sites is
|
|
1370
|
+
// deliberate: this pass already runs after every render, it already
|
|
1371
|
+
// owns *where a contained window is parented*, and a fifth gesture
|
|
1372
|
+
// that closes a leaf gets the same answer without knowing this rule
|
|
1373
|
+
// exists. The window keeps floating — it re-homes onto the tile
|
|
1374
|
+
// that survived rather than docking itself — which is the ruling:
|
|
1375
|
+
// a tile operation may move a window, never convert or destroy it.
|
|
1376
|
+
//
|
|
1377
|
+
// NOT for an adopted window (`homeContainer`): the embedder resolves
|
|
1378
|
+
// its own ground and owns the lifetime of the box it stands in.
|
|
1379
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
1380
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
1381
|
+
if (tree && !tree.get(rec.homeLeafId)) {
|
|
1382
|
+
// A TREE WITH NO CONTENT LEAF GETS ONE. `closeFocused`
|
|
1383
|
+
// keeps that invariant itself, but `moveFocusedToDesktop`
|
|
1384
|
+
// did not — with a panel open it leaves a page holding
|
|
1385
|
+
// nothing but chrome, and then there is no ground for the
|
|
1386
|
+
// windows that were standing there. Spawning it here is the
|
|
1387
|
+
// same call `closeFocused` makes and the same tile a fresh
|
|
1388
|
+
// desktop starts with; leaving `homeLeafId` dangling would
|
|
1389
|
+
// put the window back in the state above.
|
|
1390
|
+
let survivor = tree.primaryLeafId();
|
|
1391
|
+
if (!survivor) {
|
|
1392
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
1393
|
+
if (spawned) {
|
|
1394
|
+
this._seedHome(tree, spawned);
|
|
1395
|
+
this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
1396
|
+
survivor = tree.primaryLeafId();
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
if (survivor) rec.homeLeafId = survivor;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// R11. AN ADOPTED WINDOW IS CONTAINED TO A BOX INSIDE THE PANE,
|
|
1404
|
+
// not to the pane itself — a canvas ground sits within the leaf
|
|
1405
|
+
// wrap, under whatever chrome the embedder draws above it. Re-homing
|
|
1406
|
+
// such a window to the wrap would lift it out of its ground and
|
|
1407
|
+
// stand it over that chrome, which is a slower and stranger version
|
|
1408
|
+
// of the bug this whole function exists to prevent.
|
|
1409
|
+
const paneEl = rec.homeContainer
|
|
1410
|
+
? (rec.homeContainer() || null)
|
|
1411
|
+
: this.renderer.leafEl(rec.homeLeafId);
|
|
1412
|
+
// NO PANE ELEMENT, ON THE ACTIVE DESKTOP, AFTER THE REPAIR ABOVE.
|
|
1413
|
+
// What is left is an embedder's ground that has not mounted yet and
|
|
1414
|
+
// a tree with no content leaf at all — neither of which is a window
|
|
1415
|
+
// to rescue. The sentence that used to stand here said "closed, or
|
|
1416
|
+
// on another desktop … rather than orphaning it into a detached
|
|
1417
|
+
// node", and both halves were wrong: the other desktop is handled
|
|
1418
|
+
// above, and by the time this ran on a CLOSED pane the node was
|
|
1419
|
+
// already detached — `_cleanCache` (`tile_renderer.js`) runs before
|
|
1420
|
+
// `onAfterRender`, not after it.
|
|
1421
|
+
if (!paneEl) continue;
|
|
1422
|
+
if (paneEl === rec.window.container) {
|
|
1423
|
+
// THE CONTAINER IS UNCHANGED AND THE ELEMENT IS NOT IN IT.
|
|
1424
|
+
//
|
|
1425
|
+
// The desktop rule above takes a window off the page it has
|
|
1426
|
+
// left by removing the ELEMENT; it deliberately leaves
|
|
1427
|
+
// `container` alone, because the wrap is still the window's
|
|
1428
|
+
// home and re-pointing it would lose that. Coming back through
|
|
1429
|
+
// a desktop SWITCH is safe — the wrap is evicted and rebuilt,
|
|
1430
|
+
// so `moveTo` sees a new container and appends. Coming back any
|
|
1431
|
+
// other way is not: `moveWindowToDesktop` and `removeDesktop`
|
|
1432
|
+
// re-point `desktopIdx` and render with the same tree on
|
|
1433
|
+
// screen, so the wrap is still cached, `moveTo` returns false
|
|
1434
|
+
// for an unchanged container, and the window stayed detached
|
|
1435
|
+
// for good — the exact state this whole pass exists to prevent,
|
|
1436
|
+
// reached through the desktop door instead of the close door.
|
|
1437
|
+
//
|
|
1438
|
+
// `appendChild` rather than `moveTo`: nothing about the
|
|
1439
|
+
// window's home has changed, so there is no clamp to redo, no
|
|
1440
|
+
// resize observer to rebuild and no `managed-window-moved` to
|
|
1441
|
+
// announce. It is the same element going back into the same
|
|
1442
|
+
// box.
|
|
1443
|
+
const el = rec.window.element;
|
|
1444
|
+
if (el && !el.isConnected) {
|
|
1445
|
+
try { paneEl.appendChild(el); }
|
|
1446
|
+
catch (err) { console.warn('[wm] re-attach failed', err); }
|
|
1447
|
+
}
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
try { rec.window.moveTo(paneEl); }
|
|
1451
|
+
catch (err) { console.warn('[wm] re-home failed', err); }
|
|
1452
|
+
}
|
|
676
1453
|
}
|
|
677
1454
|
|
|
678
|
-
/**
|
|
679
|
-
*
|
|
1455
|
+
/**
|
|
1456
|
+
* C21, as a verb a consumer can call: put THIS window back where it belongs
|
|
1457
|
+
* and say whether it moved.
|
|
1458
|
+
*
|
|
1459
|
+
* The taskbar needs it. A minimised window's element may be out of the
|
|
1460
|
+
* document — its desktop is not on screen, or its pane was closed — and
|
|
1461
|
+
* un-minimising it in that state clears `isMinimized` (so its button
|
|
1462
|
+
* disappears, the last handle on it) while showing nothing. `restore` has
|
|
1463
|
+
* to be able to repair the window BEFORE it makes it visible, and
|
|
1464
|
+
* `_rehomeContainedWindows` is the thing that knows how; it was simply not
|
|
1465
|
+
* reachable, and `taskbar.js`'s own docstring asserted it ran for these
|
|
1466
|
+
* windows when the guard above meant it did not.
|
|
1467
|
+
*
|
|
1468
|
+
* IT SWITCHES DESKTOPS WHEN IT HAS TO, and that is the half a bare re-home
|
|
1469
|
+
* cannot do. A window belongs to one desktop; if that desktop is not on
|
|
1470
|
+
* screen, the honest answer to *show me this window* is the one every
|
|
1471
|
+
* taskbar in every window manager gives — go to where it lives. Restoring
|
|
1472
|
+
* it onto the page the user happens to be looking at would move a window
|
|
1473
|
+
* between pages as a side effect of asking to see it, and that is a tile
|
|
1474
|
+
* decision being made by a window verb.
|
|
1475
|
+
*
|
|
1476
|
+
* @param {object} win a live ManagedWindow
|
|
1477
|
+
* @returns {boolean} whether this WM owns it (and so has revealed it)
|
|
1478
|
+
*/
|
|
1479
|
+
revealWindow(win) {
|
|
1480
|
+
if (!win) return false;
|
|
1481
|
+
let rec = null;
|
|
1482
|
+
for (const [, r] of this._windowToLeaf) {
|
|
1483
|
+
if (r.window === win) { rec = r; break; }
|
|
1484
|
+
}
|
|
1485
|
+
if (!rec) return false;
|
|
1486
|
+
// AN UNCONTAINED WINDOW HAS NO PAGE, SO THERE IS NOTHING TO REVEAL.
|
|
1487
|
+
// `_navigateWindow`'s windows (Alt+N, "Open a copy in a window") float
|
|
1488
|
+
// over the root and stay visible on every desktop; `desktopIdx` on such
|
|
1489
|
+
// a record is only where it happened to be OPENED. Switching to it
|
|
1490
|
+
// would throw the user off the page they are on to show them a window
|
|
1491
|
+
// that was already in front of them. Returning false hands the restore
|
|
1492
|
+
// back to the embedder, which is right for the canvas registry's
|
|
1493
|
+
// windows too.
|
|
1494
|
+
if (!rec.homeLeafId && !rec.homeContainer) return false;
|
|
1495
|
+
if (rec.desktopIdx !== this.desktops.activeIdx
|
|
1496
|
+
&& this.desktops.desktops[rec.desktopIdx]) {
|
|
1497
|
+
// Renders, and the render re-homes — so there is nothing to do
|
|
1498
|
+
// afterwards, and doing it twice would be a second repaint.
|
|
1499
|
+
this.switchDesktop(rec.desktopIdx);
|
|
1500
|
+
} else {
|
|
1501
|
+
this._rehomeContainedWindows();
|
|
1502
|
+
}
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* R12. The rectangle an ESCAPED window may occupy — the tiles, and not the
|
|
1508
|
+
* panels — in the root's own coordinates.
|
|
1509
|
+
*
|
|
1510
|
+
* R1 let a window leave its pane so it could reach another one, and the
|
|
1511
|
+
* cheapest box to let it leave into is the root every tile shares. But the
|
|
1512
|
+
* root holds the docked panels too, so the bottom edge stopped being an
|
|
1513
|
+
* edge: a window could be dragged down over the bottom panel and dropped
|
|
1514
|
+
* there, half-covering a surface that has its own scroll and its own
|
|
1515
|
+
* chrome, with no way to tell it had happened except that it looked wrong.
|
|
1516
|
+
*
|
|
1517
|
+
* The answer is the UNION OF THE CONTENT LEAVES rather than "the root minus
|
|
1518
|
+
* the panel I know about": panels dock left, right and bottom, an embedder
|
|
1519
|
+
* may show any combination of them, and each one may be collapsed. A union
|
|
1520
|
+
* of the tiles is right for all of those without enumerating any of them,
|
|
1521
|
+
* and it degrades to the root when a desktop is somehow all panel.
|
|
1522
|
+
*/
|
|
1523
|
+
_tileBounds() {
|
|
1524
|
+
const root = this.rootEl;
|
|
1525
|
+
if (!root) return null;
|
|
1526
|
+
const layer = this._layerRect();
|
|
1527
|
+
if (!layer) return null;
|
|
1528
|
+
const rootRect = root.getBoundingClientRect();
|
|
1529
|
+
// Into the host's coordinates. `left`/`top` are written against the
|
|
1530
|
+
// PADDING box, so the root's own border comes off as well — the same
|
|
1531
|
+
// conversion the escape does, and for the same reason.
|
|
1532
|
+
const ox = rootRect.left + root.clientLeft - root.scrollLeft;
|
|
1533
|
+
const oy = rootRect.top + root.clientTop - root.scrollTop;
|
|
1534
|
+
return { minX: layer.left - ox, minY: layer.top - oy,
|
|
1535
|
+
width: layer.width, height: layer.height };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* R13. THE LAYER, in the VIEWPORT pixels a hit-test speaks — the union of
|
|
1540
|
+
* the content leaves, before it is converted into anybody's coordinates.
|
|
1541
|
+
*
|
|
1542
|
+
* This is `_tileBounds` with the last step taken off, and it stays a
|
|
1543
|
+
* separate function rather than being folded back into it because the two
|
|
1544
|
+
* frames have different readers: `_tileBounds` answers `dragBounds`, which
|
|
1545
|
+
* `ManagedWindow._bounds()` uses to clamp a window in the ROOT's
|
|
1546
|
+
* coordinates, and this answers anything measuring against the page.
|
|
1547
|
+
*
|
|
1548
|
+
* R14 REMOVED ITS OTHER READER. R13's maximise preview was drawn from here
|
|
1549
|
+
* so that it would be the same measurement `toggleMaximize` would deliver
|
|
1550
|
+
* through `dragBounds` — C15's rule, THE PREVIEW MAY NOT PROMISE A
|
|
1551
|
+
* RECTANGLE THE DROP DOES NOT DELIVER, applied to the one mode that did not
|
|
1552
|
+
* dock. There is no such mode now: the top edge docks like every other
|
|
1553
|
+
* zone, its preview is the TILE (`_homeDockTarget`), and the layer's only
|
|
1554
|
+
* remaining job is the clamp. Kept as its own function because the clamp
|
|
1555
|
+
* still needs the union of the CONTENT leaves rather than the root, which
|
|
1556
|
+
* is a definition, not a call site.
|
|
1557
|
+
*
|
|
1558
|
+
* The union of the CONTENT LEAVES rather than the root, for the reason
|
|
1559
|
+
* `_tileBounds` gives at length: the root holds the docked panels too.
|
|
1560
|
+
*
|
|
1561
|
+
* Null when nothing has a box yet — a layout that has not happened, a
|
|
1562
|
+
* desktop whose tiles are all zero-sized. Every caller treats that as "do
|
|
1563
|
+
* not promise anything", which is the only honest answer available.
|
|
1564
|
+
*/
|
|
1565
|
+
_layerRect() {
|
|
1566
|
+
let l = Infinity, tp = Infinity, r = -Infinity, b = -Infinity;
|
|
1567
|
+
for (const leaf of this._tree().leaves()) {
|
|
1568
|
+
if (PANEL_KINDS.has(leaf.content?.kind)) continue;
|
|
1569
|
+
const el = this.renderer.leafEl(leaf.id);
|
|
1570
|
+
if (!el) continue;
|
|
1571
|
+
const box = el.getBoundingClientRect();
|
|
1572
|
+
if (!box.width || !box.height) continue;
|
|
1573
|
+
l = Math.min(l, box.left); tp = Math.min(tp, box.top);
|
|
1574
|
+
r = Math.max(r, box.right); b = Math.max(b, box.bottom);
|
|
1575
|
+
}
|
|
1576
|
+
if (!Number.isFinite(l)) return null;
|
|
1577
|
+
return { left: l, top: tp, width: r - l, height: b - tp };
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** Dock the window's content back into the tile it came from — or, when it
|
|
1581
|
+
* came from none, into the desktop's primary tile — then close the window.
|
|
1582
|
+
*
|
|
1583
|
+
* R7. This is what the MAXIMIZE button now does, so it is reached far more
|
|
1584
|
+
* often than it was as a button of its own, and "somewhere other than where
|
|
1585
|
+
* the window came from" stopped being a defensible answer. Promoting a pane
|
|
1586
|
+
* re-seeds it with the root kind — ground for the window to stand on — so
|
|
1587
|
+
* FILLING that pane is the exact inverse: the content goes back where it
|
|
1588
|
+
* was lifted from, replacing the ground it has been standing on.
|
|
1589
|
+
*
|
|
1590
|
+
* A home pane that has since acquired content is a different story. The
|
|
1591
|
+
* user opened something there, and replacing it would destroy work the
|
|
1592
|
+
* window knows nothing about, so the content joins it as a tab instead.
|
|
1593
|
+
* With no home pane at all — an Alt+N window, or any window under an
|
|
1594
|
+
* embedder that does not confine promotions — this is the primary-tile tab
|
|
1595
|
+
* it has always been. */
|
|
680
1596
|
bringBackWindow(windowId) {
|
|
681
1597
|
const rec = this._windowToLeaf.get(windowId);
|
|
682
|
-
|
|
1598
|
+
// C28. IT SAYS SO WHEN IT DOES NOTHING. This resolves through
|
|
1599
|
+
// `_windowToLeaf` and is silent for a window the WM never built and
|
|
1600
|
+
// never adopted — correct, since there is no tile to bring such a
|
|
1601
|
+
// window back to. The `onMaximize` wrappers below returned `true`
|
|
1602
|
+
// regardless, so `toggleMaximize` treated the gesture as CLAIMED and
|
|
1603
|
+
// returned early, and the window neither docked nor maximised: the
|
|
1604
|
+
// button and the double-click both did nothing at all, which is a
|
|
1605
|
+
// dead control rather than a limitation anybody can read. Returning
|
|
1606
|
+
// false lets the geometric maximise happen instead — the answer every
|
|
1607
|
+
// other window manager gives a window with nowhere to go back to.
|
|
1608
|
+
if (!rec) return false;
|
|
1609
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
1610
|
+
const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
|
|
1611
|
+
if (home && home.kind === 'leaf') {
|
|
1612
|
+
rec._dock = { leafId: rec.homeLeafId,
|
|
1613
|
+
mode: this._isStartTile(home) ? 'fill' : 'tab' };
|
|
1614
|
+
}
|
|
683
1615
|
rec._demoting = true;
|
|
684
1616
|
try { rec.window.close({ force: true }); }
|
|
685
1617
|
catch (err) { console.warn('[wm] bringBack: close failed', err); }
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* R11. ADOPT A WINDOW THE EMBEDDER BUILT ITSELF.
|
|
1623
|
+
*
|
|
1624
|
+
* Everything R1–R10 gave a window — escaping its pane for the length of a
|
|
1625
|
+
* drag, going half-transparent once it is outside, the edge/body/ground
|
|
1626
|
+
* drops, maximise meaning *back to tile* — is wired in `_promote`, and so
|
|
1627
|
+
* belongs only to windows this WM lifted out of the tree. An embedder that
|
|
1628
|
+
* stands its own `ManagedWindow` on a pane (`snap: true` against the pane's
|
|
1629
|
+
* ground) got none of it: `_snapCommit` resolves the window through
|
|
1630
|
+
* `_windowToLeaf` and returns false for one it never built, so every drop
|
|
1631
|
+
* previewed correctly and then quietly did nothing.
|
|
1632
|
+
*
|
|
1633
|
+
* The fix is not to make the WM build those windows — the embedder has its
|
|
1634
|
+
* own reasons for the ones it builds, and taking that over would mean
|
|
1635
|
+
* taking over their content, their identity and their lifetime. It is to
|
|
1636
|
+
* let a window JOIN the tree's world after the fact, which needs exactly
|
|
1637
|
+
* two things: the drag options set on the component, and a record saying
|
|
1638
|
+
* what content to restore when the window is docked.
|
|
1639
|
+
*
|
|
1640
|
+
* CALL THIS BEFORE `show()`. `maximizeIcon` is read when the chrome is
|
|
1641
|
+
* built (`managed_window.js:703`) and the chrome is built lazily by `show`
|
|
1642
|
+
* (`:281`), so a window adopted afterwards would carry the right behaviour
|
|
1643
|
+
* behind a button still drawing a square.
|
|
1644
|
+
*
|
|
1645
|
+
* TEARDOWN STAYS THE EMBEDDER'S. `mountInfo` is optional and normally
|
|
1646
|
+
* omitted: a window that already destroys its own content in its `onClose`
|
|
1647
|
+
* would otherwise destroy it twice, once here and once there. The
|
|
1648
|
+
* embedder's handler is chained, not replaced, and runs after this one — so
|
|
1649
|
+
* a dock has already re-mounted the content into the tile by the time the
|
|
1650
|
+
* window's own teardown disposes of the copy that was floating.
|
|
1651
|
+
*
|
|
1652
|
+
* @param {object} win a live ManagedWindow, not yet shown
|
|
1653
|
+
* @param {object} spec
|
|
1654
|
+
* @param {string} spec.kind content kind to restore into a tile
|
|
1655
|
+
* @param {object} [spec.props] its props
|
|
1656
|
+
* @param {string} [spec.title] the tab title after a dock
|
|
1657
|
+
* @param {string} [spec.homeLeafId] the pane it stands on: what "back to
|
|
1658
|
+
* tile" targets, and what the probe stays silent inside
|
|
1659
|
+
* @param {function} [spec.homeContainer] `() => HTMLElement` — the box
|
|
1660
|
+
* WITHIN that pane the window is contained to. A canvas pane's
|
|
1661
|
+
* ground is not the leaf wrap, and re-homing to the wrap after a
|
|
1662
|
+
* repaint would lift the window out of the ground it belongs to.
|
|
1663
|
+
* @param {object} [spec.mountInfo] `{destroy}`, if teardown is ours
|
|
1664
|
+
* @returns {string|null} the window id, or null if it could not be adopted
|
|
1665
|
+
*/
|
|
1666
|
+
adoptWindow(win, spec = {}) {
|
|
1667
|
+
const winId = win?.id;
|
|
1668
|
+
if (!winId || !spec.kind) return null;
|
|
1669
|
+
if (this._windowToLeaf.has(winId)) return winId;
|
|
1670
|
+
|
|
1671
|
+
// The same bag `_promote` builds, and deliberately the same values: two
|
|
1672
|
+
// windows on one desktop behaving differently under the same gesture is
|
|
1673
|
+
// the kind of difference a user reads as a bug in whichever one they
|
|
1674
|
+
// tried second.
|
|
1675
|
+
if (this.snapPromotion) {
|
|
1676
|
+
win.snap = win.snap && win.canDrag && win.canResize;
|
|
1677
|
+
win.snapController = this._snapController();
|
|
1678
|
+
}
|
|
1679
|
+
win.dragHost = () => this.rootEl;
|
|
1680
|
+
win.dragBounds = () => this._tileBounds();
|
|
1681
|
+
win.onMaximize = () => this.bringBackWindow(winId);
|
|
1682
|
+
win.maximizeIcon = 'close_fullscreen';
|
|
1683
|
+
win.maximizeTitle = 'Back to tile';
|
|
1684
|
+
|
|
1685
|
+
const original = { kind: spec.kind, props: spec.props || {},
|
|
1686
|
+
title: spec.title || spec.kind };
|
|
1687
|
+
this._windowToLeaf.set(winId, {
|
|
1688
|
+
leafId: null,
|
|
1689
|
+
desktopIdx: this.desktops.activeIdx,
|
|
1690
|
+
original,
|
|
1691
|
+
mountInfo: spec.mountInfo || null,
|
|
1692
|
+
window: win,
|
|
1693
|
+
contentEl: null, bodyEl: null, tabBarEl: null,
|
|
1694
|
+
tabs: [original], activeTabIdx: 0, strip: null,
|
|
1695
|
+
homeLeafId: spec.homeLeafId || null,
|
|
1696
|
+
homeContainer: spec.homeContainer || null,
|
|
1697
|
+
adopted: true,
|
|
1698
|
+
_demoting: false,
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
const prior = win.onClose;
|
|
1702
|
+
win.onClose = () => {
|
|
1703
|
+
this._onManagedWindowClosed(winId, null);
|
|
1704
|
+
prior?.();
|
|
1705
|
+
};
|
|
1706
|
+
return winId;
|
|
686
1707
|
}
|
|
687
1708
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
*
|
|
691
|
-
*
|
|
1709
|
+
// ══ C15. Snap-to-promote ══════════════════════════════════════════
|
|
1710
|
+
/**
|
|
1711
|
+
* The snap controller a promoted window is given. It answers the two
|
|
1712
|
+
* questions ManagedWindow's own C11 snap cannot, because both are about a
|
|
1713
|
+
* tree it does not know exists:
|
|
1714
|
+
*
|
|
1715
|
+
* probe which TILE is under the pointer, and — since R15 — which edge
|
|
1716
|
+
* of it THE DRAGGED WINDOW'S OWN BORDERS have reached, and what
|
|
1717
|
+
* would dropping there actually produce: a half of that tile, a
|
|
1718
|
+
* quarter of the layer, the tile entire, or (R13, at the top edge
|
|
1719
|
+
* of the pane the window already stands on) the whole layer,
|
|
1720
|
+
* which is the one answer that is not a dock at all. The preview
|
|
1721
|
+
* draws exactly that rectangle, because a preview that promises a
|
|
1722
|
+
* half and delivers a quarter is worse than no preview.
|
|
1723
|
+
* commit put the window in the tree — or, for R13's maximise, leave it
|
|
1724
|
+
* floating and give it the layer. Over an EMPTY tile the dock is
|
|
1725
|
+
* unambiguous and happens on release. Over an OCCUPIED tile the
|
|
1726
|
+
* edges are unambiguous too — the drag chose a side, so the
|
|
1727
|
+
* side is the split — and only the CENTRE was ever genuinely a
|
|
1728
|
+
* question, which is why it is the zone that changed most.
|
|
1729
|
+
*
|
|
1730
|
+
* Built once and reused: the probe runs per pointermove and allocating a
|
|
1731
|
+
* closure per window per drag is free, but the memo keeps the identity
|
|
1732
|
+
* stable for anyone comparing controllers.
|
|
1733
|
+
*/
|
|
1734
|
+
_snapController() {
|
|
1735
|
+
if (this._snapCtl) return this._snapCtl;
|
|
1736
|
+
this._snapCtl = {
|
|
1737
|
+
probe: (e, win) => this._snapProbe(e, win),
|
|
1738
|
+
commit: (probe, win) => this._snapCommit(probe, win),
|
|
1739
|
+
};
|
|
1740
|
+
return this._snapCtl;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* How close to a tile's edge the DRAGGED WINDOW'S matching edge must come
|
|
1745
|
+
* for a dock to arm — in PIXELS, and a narrow band. Since R15 it is also
|
|
1746
|
+
* the minimum distance the drag must have travelled toward that edge
|
|
1747
|
+
* inside the window's own pane; `_snapSide` argues both, and this is the
|
|
1748
|
+
* one constant either of them is measured in.
|
|
1749
|
+
*
|
|
1750
|
+
* This was a third of the tile, measured as a fraction, with the remaining
|
|
1751
|
+
* middle ninth treated as a fourth zone that offered a three-way choice.
|
|
1752
|
+
* Both halves of that were wrong, and together they made docking the
|
|
1753
|
+
* DEFAULT rather than a deliberate gesture:
|
|
1754
|
+
*
|
|
1755
|
+
* - A fraction means the band grows with the tile. On a maximised layer
|
|
1756
|
+
* a "third" is several hundred pixels, so a window could not be moved
|
|
1757
|
+
* anywhere near the left half of the screen without arming a split.
|
|
1758
|
+
* - The centre zone armed over the whole middle of every tile and
|
|
1759
|
+
* previewed the ENTIRE tile, so simply picking a window up and moving
|
|
1760
|
+
* it a few pixels lit the whole pane. Every move looked like a dock
|
|
1761
|
+
* because every move WAS one.
|
|
1762
|
+
*
|
|
1763
|
+
* Aero snap is an edge gesture: you push THE WINDOW at an edge — which is
|
|
1764
|
+
* what R15 finally made it measure. So the band is a fixed 28px from the
|
|
1765
|
+
* edge, and what lies past it is decided by the
|
|
1766
|
+
* pane rather than by the pointer: in the window's OWN pane the centre
|
|
1767
|
+
* arms nothing at all and the drop is simply a window that moved (R2), and
|
|
1768
|
+
* in any other pane it is the non-destructive tab or fill of R5/R6. The
|
|
1769
|
+
* band itself never grows with the tile, which is the whole of the fix.
|
|
1770
|
+
* Docking a whole tile is also still available without any drag at all —
|
|
1771
|
+
* the "back to tile" button in the window's own chrome, which names the
|
|
1772
|
+
* destination instead of guessing it.
|
|
1773
|
+
*/
|
|
1774
|
+
static get SNAP_EDGE_PX() { return 28; }
|
|
1775
|
+
|
|
1776
|
+
_snapProbe(e, win) {
|
|
1777
|
+
const leafEl = this._leafElAt(e.clientX, e.clientY, win);
|
|
1778
|
+
if (!leafEl) return null;
|
|
1779
|
+
// R2, NARROWED BY R13. THE PANE IT CAME FROM ARMS AT ITS EDGES AND
|
|
1780
|
+
// NOWHERE ELSE — its centre still arms nothing at all.
|
|
1781
|
+
//
|
|
1782
|
+
// The rule was a blanket one: the origin pane armed nothing anywhere,
|
|
1783
|
+
// neither an edge nor the centre. It answered a real complaint — a
|
|
1784
|
+
// window that lives on a pane could not be nudged two pixels without
|
|
1785
|
+
// the pane lighting up to say it was about to swallow it — but it
|
|
1786
|
+
// answered it with more than the complaint asked for. A nudge happens
|
|
1787
|
+
// in the MIDDLE of a pane, which is where a window sits and where a
|
|
1788
|
+
// hand moving it goes; the 28px edge bands are somewhere a drag only
|
|
1789
|
+
// arrives on purpose — which under R15, where the bands are tested
|
|
1790
|
+
// against the WINDOW's borders and a window can already be sitting in
|
|
1791
|
+
// one before the hand touches it, is true only because `_snapSide`'s
|
|
1792
|
+
// direction guard makes it true. That guard is R2's other half and it
|
|
1793
|
+
// is why it applies in this pane and nowhere else.
|
|
1794
|
+
// So the centre is still silent, the nudge is still
|
|
1795
|
+
// fixed in full, and the edges of a window's own pane now mean what
|
|
1796
|
+
// they mean in every other pane — with one addition, TOP, which in the
|
|
1797
|
+
// pane a window already occupies has nothing to split and means
|
|
1798
|
+
// MAXIMISE instead (see below).
|
|
1799
|
+
//
|
|
1800
|
+
// DECIDED HERE, SPENT BELOW. `dragOrigin` is in hand at this point and
|
|
1801
|
+
// the answer cannot change while the probe runs, but which zone it
|
|
1802
|
+
// applies to is not known until `side` is. The origin is the window's
|
|
1803
|
+
// own answer (`dragOrigin` — the container its drag escaped), so the
|
|
1804
|
+
// two halves of the rule cannot disagree: the same boundary that turns
|
|
1805
|
+
// the window half-transparent is the one that arms the probe.
|
|
1806
|
+
//
|
|
1807
|
+
// A window with no origin — one opened straight into a float, which
|
|
1808
|
+
// never belonged to a pane — has no inside to be in, and every pane
|
|
1809
|
+
// under it is foreign. It arms everywhere, which is correct: there is
|
|
1810
|
+
// no "just moving it around at home" for a window with no home. It also
|
|
1811
|
+
// never maximises by this gesture, and that is not an omission — see
|
|
1812
|
+
// the maximise branch, which can only promise the layer for a window
|
|
1813
|
+
// whose bounds are the layer.
|
|
1814
|
+
//
|
|
1815
|
+
// CONTAINS, not equals. The WM confines its own promotions to the leaf
|
|
1816
|
+
// WRAP, but an embedder is free to confine a window to a box it built
|
|
1817
|
+
// inside the leaf — a canvas pane whose ground is a sibling of its own
|
|
1818
|
+
// taskbar strip does exactly that — and that window's home pane is the
|
|
1819
|
+
// leaf around it. `contains` is true of the element itself, so the
|
|
1820
|
+
// WM's own case is the same test.
|
|
1821
|
+
const own = !!(win?.dragOrigin && leafEl.contains(win.dragOrigin));
|
|
1822
|
+
const leafId = leafEl.dataset.leafId;
|
|
1823
|
+
// THE ACTIVE DESKTOP, and the dock has to agree. A promoted window is
|
|
1824
|
+
// global — it stays on screen across a desktop switch — so the tile
|
|
1825
|
+
// under the pointer belongs to whichever desktop is showing, not to the
|
|
1826
|
+
// one the window was promoted from. `_onManagedWindowClosed` applies
|
|
1827
|
+
// `_dock` against `rec.desktopIdx`, so the drop carries the index it
|
|
1828
|
+
// was made on and re-homes the record to it.
|
|
1829
|
+
const desktopIdx = this.desktops.activeIdx;
|
|
1830
|
+
const leaf = this._tree().get(leafId);
|
|
1831
|
+
if (!leaf || leaf.kind !== 'leaf') return null;
|
|
1832
|
+
// Panel tiles are chrome, not content. A window dropped on the
|
|
1833
|
+
// navigator has nowhere to go and the preview must not suggest it has.
|
|
1834
|
+
if (String(leaf.content?.kind || '').startsWith('panel:')) return null;
|
|
1835
|
+
|
|
1836
|
+
const r = leafEl.getBoundingClientRect();
|
|
1837
|
+
// R4, RE-AIMED BY R15. PIXELS FROM EACH EDGE, not fractions of the
|
|
1838
|
+
// tile — and the edges measured are now THE DRAGGED WINDOW'S, not the
|
|
1839
|
+
// pointer's. `_snapSide` owns that argument in full, including the
|
|
1840
|
+
// corner precedence and the one case that still falls back to the
|
|
1841
|
+
// pointer.
|
|
1842
|
+
//
|
|
1843
|
+
// One exclusion survives unchanged: a leaf with NO content at all has
|
|
1844
|
+
// no edges, because splitting nothing produces two nothings and the
|
|
1845
|
+
// preview would be drawing a half of a pane that has nothing to halve.
|
|
1846
|
+
const side = leaf.content ? this._snapSide(r, e, win, own) : null;
|
|
1847
|
+
// R14 (was R13). TOP, IN THE WINDOW'S OWN PANE, IS *BACK TO TILE*.
|
|
1848
|
+
//
|
|
1849
|
+
// Everywhere else `top` splits off the pane's upper half. In the pane
|
|
1850
|
+
// the window is already floating over, that is the one edge with
|
|
1851
|
+
// nothing to say — splitting a pane in order to put a window into the
|
|
1852
|
+
// half of it the window already covers is a gesture whose only effect
|
|
1853
|
+
// is work to undo. Aero's answer is the one worth copying: the top edge
|
|
1854
|
+
// is the "make this as big as it goes" edge.
|
|
1855
|
+
//
|
|
1856
|
+
// R13 read that as the GEOMETRIC maximise and made "as big as it goes"
|
|
1857
|
+
// mean THE LAYER. The product owner overruled it — *"putting a window
|
|
1858
|
+
// to maximize > maximize here means back to tile"* — and, asked how far
|
|
1859
|
+
// that went, chose everywhere, adding *"this is not yet correct for the
|
|
1860
|
+
// snap at top gesture"*, which names this zone as the part still wrong.
|
|
1861
|
+
// So there is now ONE meaning of maximise in the whole layer: the
|
|
1862
|
+
// window stops being a window and its content goes back into a tile.
|
|
1863
|
+
// The chrome button, the title bar's double-click, the embedder's
|
|
1864
|
+
// window menu and this edge are four doors onto one verb, which is the
|
|
1865
|
+
// only arrangement in which a user learns it once. No geometric
|
|
1866
|
+
// maximise remains reachable by gesture; `toggleMaximize({claimable:
|
|
1867
|
+
// false})` stays on `ManagedWindow` as the library escape hatch for a
|
|
1868
|
+
// consumer that wants the rectangle, and nothing in here calls it.
|
|
1869
|
+
//
|
|
1870
|
+
// THE RECT IS THE TILE, and it must be the tile the DOCK picks rather
|
|
1871
|
+
// than the tile the pointer happens to be in. That is C15's rule, which
|
|
1872
|
+
// R13 obeyed in the other direction, and it is why this branch resolves
|
|
1873
|
+
// its destination up front instead of committing blind:
|
|
1874
|
+
// `_homeDockTarget` is `bringBackWindow`'s own resolution read out, so
|
|
1875
|
+
// the preview and the drop cannot disagree — one function, called from
|
|
1876
|
+
// both halves.
|
|
1877
|
+
//
|
|
1878
|
+
// NOTHING TO PROMISE, NOTHING DRAWN. A window the WM never adopted, a
|
|
1879
|
+
// home leaf on a desktop that is not showing, a layout that has not
|
|
1880
|
+
// happened: `_homeDockTarget` answers null and the top edge falls
|
|
1881
|
+
// SILENT, which is exactly what the CENTRE of the window's own pane
|
|
1882
|
+
// already does (R2's surviving half, a dozen lines below). That is not
|
|
1883
|
+
// the dead-control failure the disabled-item convention exists to
|
|
1884
|
+
// avoid — the release still runs `_endDragEscape`, which puts the
|
|
1885
|
+
// window back where the drag found it, a visible outcome and the same
|
|
1886
|
+
// one the centre gives. Arming, previewing a tile and THEN declining on
|
|
1887
|
+
// release is the bait-and-switch, and it is the thing forbidden here.
|
|
1888
|
+
if (own && side === 'top') {
|
|
1889
|
+
const home = this._homeDockTarget(win);
|
|
1890
|
+
if (!home) return null;
|
|
1891
|
+
return { key: `${home.leafId}:home`, rect: home.rect,
|
|
1892
|
+
leafId: home.leafId, desktopIdx, side, mode: 'home',
|
|
1893
|
+
leafRect: r };
|
|
1894
|
+
}
|
|
1895
|
+
return this._dropZoneFor({ leafId, leaf, r, side, own, desktopIdx });
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* R17 (C33). THE ZONE MATRIX'S TAIL — SPLIT / NOTHING / TAB / FILL — SHARED
|
|
1900
|
+
* BY THE TWO THINGS THAT CAN BE DROPPED ON A TILE.
|
|
1901
|
+
*
|
|
1902
|
+
* A dragged WINDOW and a dragged TAB ask the same question of a pane: given
|
|
1903
|
+
* that the pointer is in this leaf and the edge test answered `side`, what
|
|
1904
|
+
* would releasing here produce? Every answer below was written for the
|
|
1905
|
+
* window drop and every one of them is right for a tab, so this is an
|
|
1906
|
+
* extraction and not a generalisation — `_snapProbe` keeps everything ABOVE
|
|
1907
|
+
* it unchanged, including R14's `own && side === 'top'` home branch, which
|
|
1908
|
+
* is a window's alone (a tab has no window to bring back) and therefore
|
|
1909
|
+
* stays where it was, between the side computation and this call.
|
|
1910
|
+
*
|
|
1911
|
+
* The alternative was a second copy in `tabDropProbe`, and a second copy of
|
|
1912
|
+
* a matrix the product owner has already revised four times (R2, R4, R5/R6,
|
|
1913
|
+
* R14) is a guarantee that the two gestures will one day disagree about
|
|
1914
|
+
* what the centre of a start tile means. `web/js/shell/snap_zones.test.mjs`
|
|
1915
|
+
* in the Tables consumer asserts every cell of the window matrix and is the
|
|
1916
|
+
* regression gate on this extraction: byte-identical window behaviour is
|
|
1917
|
+
* the whole of its back-compatibility claim.
|
|
1918
|
+
*/
|
|
1919
|
+
_dropZoneFor({ leafId, leaf, r, side, own, desktopIdx }) {
|
|
1920
|
+
if (side) {
|
|
1921
|
+
return { key: `${leafId}:${side}`, rect: _halfOf(r, side),
|
|
1922
|
+
leafId, desktopIdx, side, mode: 'split', leafRect: r };
|
|
1923
|
+
}
|
|
1924
|
+
// R2, THE HALF THAT SURVIVED. Past the edge bands, inside the pane it
|
|
1925
|
+
// came from, a window is being MOVED and not docked: no key, no
|
|
1926
|
+
// preview, and on release `_endDragEscape` simply puts it back where
|
|
1927
|
+
// the drag left it. This sits after the edges and before the centre
|
|
1928
|
+
// because it is only the centre it refuses — an own-pane leaf with no
|
|
1929
|
+
// content has no edges either (`side` stays null above), and refusing
|
|
1930
|
+
// that is right for the same reason: there is nothing to dock into that
|
|
1931
|
+
// the window is not already standing on.
|
|
1932
|
+
//
|
|
1933
|
+
// A TAB READS THIS CELL THE SAME WAY, arrived at from the other side:
|
|
1934
|
+
// dropping a tab into the pane it already lives in is a gesture whose
|
|
1935
|
+
// only effect is work to undo, which is R2's argument with the word
|
|
1936
|
+
// "nudge" removed.
|
|
1937
|
+
if (own) return null;
|
|
1938
|
+
|
|
1939
|
+
// R5/R6. THE CENTRE IS NO LONGER NOTHING — it is the other two thirds
|
|
1940
|
+
// of the model, and which one it is depends on what the pane already is.
|
|
1941
|
+
//
|
|
1942
|
+
// a pane with content → a new TAB in it. Non-destructive, and the
|
|
1943
|
+
// honest reading of "dropping where an
|
|
1944
|
+
// existing tile is would cause a new tab".
|
|
1945
|
+
// a START tile → the window FILLS the pane, which is the
|
|
1946
|
+
// same verb as "back to tile": the window
|
|
1947
|
+
// stops floating and becomes that pane's
|
|
1948
|
+
// content. Under R14 that is what the top
|
|
1949
|
+
// edge of the window's OWN pane now means as
|
|
1950
|
+
// well — the difference between the two is
|
|
1951
|
+
// only which tile is named, this one the pane
|
|
1952
|
+
// under the pointer and R14's the pane the
|
|
1953
|
+
// window came from. A start tile is ground —
|
|
1954
|
+
// there is nothing there to lose, which is
|
|
1955
|
+
// exactly why this one may replace rather
|
|
1956
|
+
// than append.
|
|
1957
|
+
//
|
|
1958
|
+
// The preview is the WHOLE pane for both, because the whole pane is
|
|
1959
|
+
// what the window ends up occupying either way. C15's rule stands: the
|
|
1960
|
+
// preview may not promise a rectangle the drop does not deliver.
|
|
1961
|
+
const fills = !leaf.content || this._isStartTile(leaf);
|
|
1962
|
+
return { key: `${leafId}:${fills ? 'fill' : 'tab'}`, rect: _halfOf(r, null),
|
|
1963
|
+
leafId, desktopIdx, side: null,
|
|
1964
|
+
mode: fills ? 'fill' : 'tab', leafRect: r };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* R18 (C33). THE SAME PROBE, FOR A DRAGGED TAB — PUBLIC, because the
|
|
1969
|
+
* renderer is what holds the drag and the renderer is not the WM.
|
|
1970
|
+
*
|
|
1971
|
+
* ══ WHY THIS IS NOT `_snapProbe(e, null)` ═══════════════════════════
|
|
1972
|
+
*
|
|
1973
|
+
* It very nearly is, and the geometry underneath is literally the same
|
|
1974
|
+
* code: `_snapSide(r, e, null, false)` falls to the POINTER-distance branch
|
|
1975
|
+
* by construction — `_draggedRect(null)` is null, so `dist` takes the
|
|
1976
|
+
* `e.clientX/Y` arm and `along` scores every edge zero. A tab has no
|
|
1977
|
+
* rectangle being dragged and no `_dragState`, and that is not a gap to
|
|
1978
|
+
* paper over: the pointer IS the whole gesture for a tab, which is exactly
|
|
1979
|
+
* the pre-R15 model that `_snapSide`'s fallback preserves.
|
|
1980
|
+
*
|
|
1981
|
+
* Two things differ, and neither could be expressed by passing a null
|
|
1982
|
+
* window to `_snapProbe`:
|
|
1983
|
+
*
|
|
1984
|
+
* R14's HOME BRANCH IS A WINDOW'S. `own && side === 'top'` means "put the
|
|
1985
|
+
* window back in its tile", and a tab is already in a tile. Reaching that
|
|
1986
|
+
* branch with `win === null` would ask `_homeDockTarget(null)`, which
|
|
1987
|
+
* answers null, so the top edge of the source pane would fall silent
|
|
1988
|
+
* rather than split — a hole in the matrix produced by inheritance.
|
|
1989
|
+
*
|
|
1990
|
+
* A SINGLE-TAB SOURCE PANE ARMS NOTHING, ANYWHERE. `_dropZoneFor` already
|
|
1991
|
+
* silences the source pane's CENTRE; its edges are useful for a pane with
|
|
1992
|
+
* siblings ("tear this tab off into a split beside the others") and are a
|
|
1993
|
+
* wash for a pane with one tab, where the outcome is the pane's only
|
|
1994
|
+
* content in one half and a freshly seeded ground in the other. That is a
|
|
1995
|
+
* preview promising something no one wants, so it is refused BEFORE the
|
|
1996
|
+
* preview is drawn rather than at the drop — C15's rule is that the
|
|
1997
|
+
* rectangle drawn is the one released, and the honest way to keep it is
|
|
1998
|
+
* never to draw one.
|
|
1999
|
+
*
|
|
2000
|
+
* `own: false` is passed to `_snapSide` deliberately. Its `own` parameter
|
|
2001
|
+
* gates R2's direction guard, which measures a WINDOW's travel out of
|
|
2002
|
+
* `_dragState`; a tab drag has none, so `guarded` would be false anyway and
|
|
2003
|
+
* passing `true` would only obscure that. `own` still governs the centre,
|
|
2004
|
+
* which is why it goes to `_dropZoneFor` and not to `_snapSide`.
|
|
2005
|
+
*
|
|
2006
|
+
* @param {{clientX: number, clientY: number}} e the pointer, mid-drag
|
|
2007
|
+
* @param {{sourceLeafId?: string}} [opts] the leaf the tab left
|
|
2008
|
+
* @returns {object|null} the same probe shape a window drop produces
|
|
2009
|
+
*/
|
|
2010
|
+
tabDropProbe(e, { sourceLeafId = null } = {}) {
|
|
2011
|
+
const leafEl = this._leafElAt(e.clientX, e.clientY, null);
|
|
2012
|
+
if (!leafEl) return null;
|
|
2013
|
+
const leafId = leafEl.dataset.leafId;
|
|
2014
|
+
const tree = this._tree();
|
|
2015
|
+
const leaf = tree.get(leafId);
|
|
2016
|
+
if (!leaf || leaf.kind !== 'leaf') return null;
|
|
2017
|
+
// Panel tiles are chrome, not content — the same refusal `_snapProbe`
|
|
2018
|
+
// makes, for the same reason: there is nowhere for the drop to go and
|
|
2019
|
+
// the preview must not suggest there is.
|
|
2020
|
+
if (String(leaf.content?.kind || '').startsWith('panel:')) return null;
|
|
2021
|
+
const own = !!sourceLeafId && leafId === sourceLeafId;
|
|
2022
|
+
if (own) {
|
|
2023
|
+
const src = tree.get(sourceLeafId);
|
|
2024
|
+
const count = Array.isArray(src?.tabs) ? src.tabs.length : 0;
|
|
2025
|
+
if (count <= 1) return null;
|
|
2026
|
+
}
|
|
2027
|
+
const r = leafEl.getBoundingClientRect();
|
|
2028
|
+
const side = leaf.content ? this._snapSide(r, e, null, false) : null;
|
|
2029
|
+
return this._dropZoneFor({ leafId, leaf, r, side, own,
|
|
2030
|
+
desktopIdx: this.desktops.activeIdx });
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* R15. WHICH EDGE OF THE PANE THE *WINDOW* IS BEING PUSHED INTO.
|
|
2035
|
+
*
|
|
2036
|
+
* ══ THE BUG THIS EXISTS TO FIX ═══════════════════════════════════════
|
|
2037
|
+
*
|
|
2038
|
+
* The band was measured from the POINTER, and the pointer is wherever the
|
|
2039
|
+
* hand happened to grab the title bar. Grab a 900px window in the middle
|
|
2040
|
+
* of its bar and shove it right: `dragBounds` clamps it, its right border
|
|
2041
|
+
* sits hard against the layer's right edge, and the pointer is still 450px
|
|
2042
|
+
* away from that edge — outside every band, so nothing arms and the window
|
|
2043
|
+
* simply stops dead against the side of the screen. Reported twice:
|
|
2044
|
+
* *"snapping enables based on mouse position but actually it needs to
|
|
2045
|
+
* enable based on the dragged window bounds (e.g. window right border
|
|
2046
|
+
* distance from right snapping area)"*.
|
|
2047
|
+
*
|
|
2048
|
+
* The bigger the window the worse it got, and the gesture only ever worked
|
|
2049
|
+
* if you happened to grab near the edge you were aiming at — the bottom
|
|
2050
|
+
* edge was effectively unreachable for any tall window, because a title bar
|
|
2051
|
+
* is at the TOP of the thing you are dragging.
|
|
2052
|
+
*
|
|
2053
|
+
* So each of the four distances is now between the window's own border and
|
|
2054
|
+
* the matching border of the pane. `right` arms when the window's right
|
|
2055
|
+
* border comes within the band of the pane's right border, and so on round.
|
|
2056
|
+
*
|
|
2057
|
+
* ══ WHAT DID *NOT* CHANGE ════════════════════════════════════════════
|
|
2058
|
+
*
|
|
2059
|
+
* WHICH PANE is still the pointer's answer (`_leafElAt`), and so is `own`.
|
|
2060
|
+
* The zone matrix is about a pane — the window's own pane means something
|
|
2061
|
+
* different from any other pane — and a window can lie across three of
|
|
2062
|
+
* them at once while the pointer is in exactly one. Only the question
|
|
2063
|
+
* *"which edge of THIS pane"* moved onto the window's rectangle; the
|
|
2064
|
+
* question *"which pane"* was never the one the product owner complained
|
|
2065
|
+
* about. Everything downstream is untouched: the preview is still
|
|
2066
|
+
* `_halfOf(paneRect, side)`, so the rectangle drawn is the rectangle the
|
|
2067
|
+
* drop delivers, and a `side` reaching the branches below means exactly
|
|
2068
|
+
* what it meant before.
|
|
2069
|
+
*
|
|
2070
|
+
* ══ SHORTFALL CLAMPED AT ZERO, BECAUSE A WINDOW OVERHANGS ═════════════
|
|
2071
|
+
*
|
|
2072
|
+
* (R16 corrects R15 here. R15 said *unsigned*, and unsigned was wrong;
|
|
2073
|
+
* the paragraph below is why, and `_snapSide` carries the measurement.)
|
|
2074
|
+
*
|
|
2075
|
+
* The pointer is inside the pane by construction — `_leafElAt` found the
|
|
2076
|
+
* pane by hit-testing it — so a signed distance was always positive. A
|
|
2077
|
+
* WINDOW has no such guarantee: it is clamped to the layer, not to the
|
|
2078
|
+
* pane, so a window wider than the pane under the pointer sticks out of
|
|
2079
|
+
* both sides of it and its border is 20px PAST the pane's border rather
|
|
2080
|
+
* than 20px short of it. Both readings are "hard against that edge".
|
|
2081
|
+
*
|
|
2082
|
+
* R15 spelled that `Math.abs`, and `Math.abs` only holds the reading while
|
|
2083
|
+
* the overhang stays inside the band. Past that it counts UP again, so the
|
|
2084
|
+
* zone armed and then DISARMED as the shove continued, and a window
|
|
2085
|
+
* meaningfully wider than the pane armed nothing at all. The right spelling
|
|
2086
|
+
* is a shortfall clamped at zero: **past the edge IS the edge**, at
|
|
2087
|
+
* distance zero, and it stays there however far the shove carries it.
|
|
2088
|
+
*
|
|
2089
|
+
* ══ THE DIRECTION GUARD, WHICH IS WHAT KEEPS R2 ALIVE ════════════════
|
|
2090
|
+
*
|
|
2091
|
+
* Edge-based testing has a failure the pointer never had: a window that is
|
|
2092
|
+
* ALREADY at an edge is in that band before the drag starts. A window
|
|
2093
|
+
* parked at the left of its pane would arm a left split on the first
|
|
2094
|
+
* millimetre of any drag, and a window that fills its pane would arm on
|
|
2095
|
+
* every drag in every direction — which is precisely the *"every move
|
|
2096
|
+
* looked like a dock because every move WAS one"* failure `SNAP_EDGE_PX`
|
|
2097
|
+
* was written to end, arriving from the other direction.
|
|
2098
|
+
*
|
|
2099
|
+
* So in the window's OWN pane an edge arms only if the drag actually
|
|
2100
|
+
* carried the window at it: the pointer must have travelled more than one
|
|
2101
|
+
* band's width toward that edge since the press. A nudge (R2's complaint,
|
|
2102
|
+
* and the surviving reason the own-pane centre is silent) moves a handful
|
|
2103
|
+
* of pixels and arms nothing; a shove moves hundreds and arms the edge it
|
|
2104
|
+
* was aimed at. The band's own width is the unit, because a movement
|
|
2105
|
+
* smaller than the band cannot be the difference between being in it and
|
|
2106
|
+
* not.
|
|
2107
|
+
*
|
|
2108
|
+
* IN ANY OTHER PANE THE GUARD IS OFF, deliberately. R2 is a rule about the
|
|
2109
|
+
* pane a window already lives on — the only place a "nudge" exists. Drag a
|
|
2110
|
+
* window rightwards out of pane A and into pane B and its LEFT border is
|
|
2111
|
+
* what enters pane B first: with the guard on, aiming at the left half of
|
|
2112
|
+
* the pane to your right would be impossible, since arriving there always
|
|
2113
|
+
* means travelling right. The window is translucent by then (R3) and every
|
|
2114
|
+
* drop on a foreign pane docks, so there is no nudge to protect.
|
|
2115
|
+
*
|
|
2116
|
+
* The displacement is read from `ManagedWindow._dragState.startX/startY`,
|
|
2117
|
+
* the POINTER's position at the press — not from the window's own x/y,
|
|
2118
|
+
* which stop changing the moment the clamp bites while the gesture very
|
|
2119
|
+
* much continues. A caller with no drag state (a synthetic probe, an
|
|
2120
|
+
* embedder driving the controller by hand) yields no displacement at all
|
|
2121
|
+
* and the guard is skipped rather than failing closed: it can only ever
|
|
2122
|
+
* suppress an edge, never invent one.
|
|
2123
|
+
*
|
|
2124
|
+
* ══ CORNERS: THE PRECEDENCE, MADE EXPLICIT ═══════════════════════════
|
|
2125
|
+
*
|
|
2126
|
+
* Two edges can be in range at once, and with window borders that is no
|
|
2127
|
+
* longer the rarity it was with a pointer — shove a window into a corner
|
|
2128
|
+
* and the clamp puts BOTH borders at distance zero, exactly. Under R16 it
|
|
2129
|
+
* is not even a corner case: a window as wide as its pane is at zero on
|
|
2130
|
+
* the left AND the right for every horizontal position it can occupy, and
|
|
2131
|
+
* a floated canvas pane's window is *exactly* that wide. So the order is
|
|
2132
|
+
* stated rather than left to whichever way the loop happens to run:
|
|
2133
|
+
*
|
|
2134
|
+
* 1. NEAREST WINS. Unchanged from R4, and it is what keeps a corner from
|
|
2135
|
+
* being a dead spot: one of the two is always closer.
|
|
2136
|
+
* 2. ON A TIE, THE EDGE THE DRAG PUSHED TOWARD WINS. (R16: *toward that
|
|
2137
|
+
* edge*, signed — R15 said "the axis pushed furthest" and spelled it
|
|
2138
|
+
* `Math.abs`, which gives the two ends of one axis the SAME score, so
|
|
2139
|
+
* a left/right tie never broke at all and 'left' won every time by
|
|
2140
|
+
* loop order.) The honest tie-break is the gesture: shove it
|
|
2141
|
+
* rightwards and you get the right zone, upwards and you get the top
|
|
2142
|
+
* zone. Every zone stays reachable and which one you get is something
|
|
2143
|
+
* a hand can aim.
|
|
2144
|
+
* 3. STILL TIED — a perfect diagonal, or a probe with no drag state —
|
|
2145
|
+
* falls to the fixed order left, right, top, bottom. That is the order
|
|
2146
|
+
* the R4 loop already resolved ties in (`Object.entries` insertion
|
|
2147
|
+
* order, with a strict `<`), kept so the pointer fallback below
|
|
2148
|
+
* answers exactly what it answered before.
|
|
2149
|
+
*
|
|
2150
|
+
* ══ THE FALLBACK ═════════════════════════════════════════════════════
|
|
2151
|
+
*
|
|
2152
|
+
* With no measurable window rectangle — no element, detached, or a box of
|
|
2153
|
+
* zero area because layout has not happened — there is nothing to measure
|
|
2154
|
+
* and the pointer is the only information in the room. That path is the
|
|
2155
|
+
* pre-R15 code, unchanged, signed distances and all. It is what a headless
|
|
2156
|
+
* probe gets (jsdom lays nothing out, so every `getBoundingClientRect` is
|
|
2157
|
+
* zero), and it is why `web/js/shell/snap_zones.test.mjs` in the Tables
|
|
2158
|
+
* consumer still asserts the same matrix against the same coordinates.
|
|
2159
|
+
*
|
|
2160
|
+
* @param {DOMRect} r the pane, in viewport pixels
|
|
2161
|
+
* @param {{clientX: number, clientY: number}} e the pointer
|
|
2162
|
+
* @param {object} win the ManagedWindow being dragged
|
|
2163
|
+
* @param {boolean} own is `r` the pane this window's drag escaped?
|
|
2164
|
+
* @returns {'left'|'right'|'top'|'bottom'|null}
|
|
2165
|
+
*/
|
|
2166
|
+
_snapSide(r, e, win, own) {
|
|
2167
|
+
const edge = WindowManager.SNAP_EDGE_PX;
|
|
2168
|
+
const w = this._draggedRect(win);
|
|
2169
|
+
// The displacement is read whenever there is one, because BOTH rules
|
|
2170
|
+
// below want it and they do not want it in the same places: the corner
|
|
2171
|
+
// tie-break is about aim and applies in every pane, while the guard is
|
|
2172
|
+
// R2's rule about nudging a window at home and applies in one.
|
|
2173
|
+
const push = w ? this._dragPush(e, win) : null;
|
|
2174
|
+
const guarded = !!(own && push);
|
|
2175
|
+
// R16. SHORTFALL, CLAMPED AT ZERO — *not* `Math.abs`.
|
|
2176
|
+
//
|
|
2177
|
+
// R15 measured `Math.abs(r.right - w.right)`, which conflates the two
|
|
2178
|
+
// sides of an edge: a border 20px SHORT of the pane's border and one
|
|
2179
|
+
// 20px PAST it both read as 20. The reading past the edge is wrong,
|
|
2180
|
+
// and wrong in the direction that breaks the gesture. Two defects came
|
|
2181
|
+
// out of it and both are this one line of arithmetic:
|
|
2182
|
+
//
|
|
2183
|
+
// THE BAND OPENED AND THEN CLOSED AGAIN. Shove a window right; its
|
|
2184
|
+
// right border approaches the pane's right border, enters the 28px
|
|
2185
|
+
// band, reaches it — and keeps going. One pixel past, `abs` starts
|
|
2186
|
+
// counting UP again, and 28px past the edge the zone disarms.
|
|
2187
|
+
// Measured on the real code: a 300px window in PANE_A armed `right`
|
|
2188
|
+
// only for pointer deltas 173..227, and was null on either side. **A
|
|
2189
|
+
// firm shove disarmed the snap that a hesitant one armed** — the
|
|
2190
|
+
// product owner's original complaint arriving from the far side.
|
|
2191
|
+
//
|
|
2192
|
+
// AND A WINDOW WIDER THAN THE PANE ARMED NOTHING — though that case
|
|
2193
|
+
// is exactly what `abs` was reached for. The R15 docstring argues
|
|
2194
|
+
// for it because such a window "sticks out of both sides and its
|
|
2195
|
+
// border is 20px PAST the pane's rather than 20px short", which is
|
|
2196
|
+
// true; what it misses is that `abs` holds that reading only while
|
|
2197
|
+
// the overhang stays under 28px. Overhang by more and every edge
|
|
2198
|
+
// reads out-of-band.
|
|
2199
|
+
//
|
|
2200
|
+
// Clamping at zero says what that prose always meant: **past the edge
|
|
2201
|
+
// IS the edge.** A border level with the pane's, or beyond it, sits at
|
|
2202
|
+
// distance zero and is fully armed, and stays armed however much
|
|
2203
|
+
// further the shove carries it. "How hard are you pushing past it" was
|
|
2204
|
+
// never a measure of aim.
|
|
2205
|
+
//
|
|
2206
|
+
// This makes a same-axis TIE ordinary rather than a corner curiosity:
|
|
2207
|
+
// a window wider than its pane is hard against the left and the right
|
|
2208
|
+
// border at once, both at zero. Breaking that tie is what the signed
|
|
2209
|
+
// `along` below is for, and the two changes only make sense together.
|
|
2210
|
+
//
|
|
2211
|
+
// The pointer fallback is untouched: the pointer is inside the pane by
|
|
2212
|
+
// construction — `_leafElAt` hit-tested it — so its distances cannot
|
|
2213
|
+
// go negative and there is nothing to clamp.
|
|
2214
|
+
const dist = w
|
|
2215
|
+
? { left: Math.max(0, w.left - r.left),
|
|
2216
|
+
right: Math.max(0, r.right - w.right),
|
|
2217
|
+
top: Math.max(0, w.top - r.top),
|
|
2218
|
+
bottom: Math.max(0, r.bottom - w.bottom) }
|
|
2219
|
+
: { left: e.clientX - r.left,
|
|
2220
|
+
right: r.right - e.clientX,
|
|
2221
|
+
top: e.clientY - r.top,
|
|
2222
|
+
bottom: r.bottom - e.clientY };
|
|
2223
|
+
const toward = (name) => {
|
|
2224
|
+
if (!guarded) return true;
|
|
2225
|
+
if (name === 'left') return push.x <= -edge;
|
|
2226
|
+
if (name === 'right') return push.x >= edge;
|
|
2227
|
+
if (name === 'top') return push.y <= -edge;
|
|
2228
|
+
return push.y >= edge;
|
|
2229
|
+
};
|
|
2230
|
+
// R16. HOW FAR THE GESTURE TRAVELLED **TOWARD THIS EDGE** — signed to
|
|
2231
|
+
// the edge it is asked about, not to its axis.
|
|
2232
|
+
//
|
|
2233
|
+
// R15 wrote `Math.abs(push.x)` for both 'left' and 'right', which
|
|
2234
|
+
// makes the tie-break IDENTICAL for the two members of a same-axis
|
|
2235
|
+
// pair. `p > best.p` is then `|push.x| > |push.x|` — false, always —
|
|
2236
|
+
// so the tie fell through to the fixed loop order and **'left' won
|
|
2237
|
+
// every time, whichever way the window was shoved**.
|
|
2238
|
+
//
|
|
2239
|
+
// That was not the corner curiosity it looked like. Whenever a
|
|
2240
|
+
// window's width equals the pane's, `dist.left === dist.right` at
|
|
2241
|
+
// EVERY horizontal position, so the whole band answered 'left'; and a
|
|
2242
|
+
// canvas pane's window is sized `min(820, container.clientWidth)`,
|
|
2243
|
+
// which for any pane up to 822px wide comes out exactly equal to the
|
|
2244
|
+
// leaf's border-box width. So the common case — float a pane's window
|
|
2245
|
+
// and push it at the pane next door — always split left. Under R16's
|
|
2246
|
+
// clamped distance the tie is commoner still, because a window wider
|
|
2247
|
+
// than its pane now ties at zero on both edges by construction.
|
|
2248
|
+
//
|
|
2249
|
+
// Signing it makes the tie-break say what rule 2 always claimed:
|
|
2250
|
+
// *"the axis the drag pushed furthest wins"* — read as the DIRECTION
|
|
2251
|
+
// the drag pushed, which is the only reading a hand can aim. Shove
|
|
2252
|
+
// right, get right. A push away from the edge scores negative and
|
|
2253
|
+
// loses to any edge that was actually aimed at.
|
|
2254
|
+
//
|
|
2255
|
+
// Still zero for every edge when there is no drag state, so rule 3
|
|
2256
|
+
// falls through to the fixed order exactly as before.
|
|
2257
|
+
const along = (name) => {
|
|
2258
|
+
const dx = push?.x ?? 0, dy = push?.y ?? 0;
|
|
2259
|
+
if (name === 'left') return -dx;
|
|
2260
|
+
if (name === 'right') return dx;
|
|
2261
|
+
if (name === 'top') return -dy;
|
|
2262
|
+
return dy;
|
|
2263
|
+
};
|
|
2264
|
+
// THE FIXED ORDER IS RULE 3. Do not sort this array.
|
|
2265
|
+
let best = null;
|
|
2266
|
+
for (const name of ['left', 'right', 'top', 'bottom']) {
|
|
2267
|
+
const d = dist[name];
|
|
2268
|
+
if (!(d < edge) || !toward(name)) continue;
|
|
2269
|
+
const p = along(name);
|
|
2270
|
+
if (!best || d < best.d || (d === best.d && p > best.p)) best = { name, d, p };
|
|
2271
|
+
}
|
|
2272
|
+
return best ? best.name : null;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** R15. The dragged window's rectangle in VIEWPORT pixels — the frame a
|
|
2276
|
+
* leaf's `getBoundingClientRect` speaks, so the two are directly
|
|
2277
|
+
* comparable — or null when there is nothing to measure.
|
|
2278
|
+
*
|
|
2279
|
+
* Read off the element rather than computed from `win.x/y/width/height`,
|
|
2280
|
+
* because those are in whatever container the window is currently parented
|
|
2281
|
+
* to and mid-drag that is the drag host, not the pane being probed.
|
|
2282
|
+
*
|
|
2283
|
+
* A zero-area box is "nothing to measure" rather than a rectangle at the
|
|
2284
|
+
* origin: it is what an unlaid-out document gives, and treating it as real
|
|
2285
|
+
* would put every window in the top-left corner of every pane. Same test
|
|
2286
|
+
* `_homeDockTarget` applies to a leaf, for the same reason. */
|
|
2287
|
+
_draggedRect(win) {
|
|
2288
|
+
const el = win?.element;
|
|
2289
|
+
if (!el || el.isConnected === false) return null;
|
|
2290
|
+
if (typeof el.getBoundingClientRect !== 'function') return null;
|
|
2291
|
+
const b = el.getBoundingClientRect();
|
|
2292
|
+
if (!b || !b.width || !b.height) return null;
|
|
2293
|
+
return b;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/** R15. How far the POINTER has travelled since the press that began this
|
|
2297
|
+
* drag, or null if this window is not in a drag the WM can see.
|
|
2298
|
+
*
|
|
2299
|
+
* The pointer rather than the window: `_applyPosition` clamps the window
|
|
2300
|
+
* to `dragBounds`, so a window shoved at the edge of the layer stops
|
|
2301
|
+
* moving while the gesture continues — and "it stopped because it is
|
|
2302
|
+
* against the edge" is exactly the situation the guard must not read as
|
|
2303
|
+
* "it is not being pushed". */
|
|
2304
|
+
_dragPush(e, win) {
|
|
2305
|
+
const ds = win?._dragState;
|
|
2306
|
+
if (!ds || typeof ds.startX !== 'number' || typeof ds.startY !== 'number') return null;
|
|
2307
|
+
return { x: e.clientX - ds.startX, y: e.clientY - ds.startY };
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** The topmost `.twm-leaf` under the pointer that is not part of the window
|
|
2311
|
+
* being dragged. `elementsFromPoint` rather than `elementFromPoint`: the
|
|
2312
|
+
* dragged window IS under the pointer — it is what the pointer is holding
|
|
2313
|
+
* — and a hit-test that stops at the first element only ever finds it.
|
|
2314
|
+
*
|
|
2315
|
+
* R15 left this alone on purpose: WHICH pane is still the pointer's
|
|
2316
|
+
* answer, and only WHICH EDGE of it moved onto the window's borders. See
|
|
2317
|
+
* `_snapSide`. */
|
|
2318
|
+
_leafElAt(x, y, win) {
|
|
2319
|
+
const stack = document.elementsFromPoint(x, y);
|
|
2320
|
+
for (const el of stack) {
|
|
2321
|
+
if (win?.element && win.element.contains(el)) continue;
|
|
2322
|
+
const leafEl = el.closest?.('.twm-leaf');
|
|
2323
|
+
if (leafEl && this.rootEl.contains(leafEl)) return leafEl;
|
|
2324
|
+
}
|
|
2325
|
+
return null;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* R14. THE TILE "BACK TO TILE" WOULD PUT THIS WINDOW IN, and the rectangle
|
|
2330
|
+
* that draws it — in the VIEWPORT pixels a snap preview is positioned in.
|
|
2331
|
+
*
|
|
2332
|
+
* `bringBackWindow` resolves its destination privately and then closes the
|
|
2333
|
+
* window to reach it, which is everything a button press needs and useless
|
|
2334
|
+
* to a PREVIEW. C15's rule is that the rectangle drawn during a drag is the
|
|
2335
|
+
* one the drop delivers, and under R14 the drop delivers A TILE — so the
|
|
2336
|
+
* resolution has to be readable before the gesture is committed. Reading it
|
|
2337
|
+
* out here is what makes the top edge honest: the probe draws what this
|
|
2338
|
+
* returns and `_snapCommit` calls `bringBackWindow`, which resolves the
|
|
2339
|
+
* same way, from the same record, against the same tree.
|
|
2340
|
+
*
|
|
2341
|
+
* It is deliberately NOT a second copy of that resolution reduced to "the
|
|
2342
|
+
* home leaf". `bringBackWindow`'s fall-through — no home leaf, or one the
|
|
2343
|
+
* tree no longer has — is the desktop's PRIMARY tile, which is where
|
|
2344
|
+
* `_onManagedWindowClosed` sends a demotion carrying no `_dock`; a probe
|
|
2345
|
+
* that previewed the home leaf and then landed in the primary tile would be
|
|
2346
|
+
* the bait-and-switch with extra steps.
|
|
2347
|
+
*
|
|
2348
|
+
* `renderer.leafEl` only knows the leaves of the desktop currently on
|
|
2349
|
+
* screen, which is the property that makes the desktop check implicit: a
|
|
2350
|
+
* window whose home is on another desktop resolves to no element, this
|
|
2351
|
+
* answers null, and the top edge arms nothing rather than previewing a
|
|
2352
|
+
* rectangle on a desktop the user cannot see.
|
|
2353
|
+
*
|
|
2354
|
+
* @param {object} win a live ManagedWindow
|
|
2355
|
+
* @returns {{leafId: string, rect: DOMRect}|null} null when there is
|
|
2356
|
+
* nothing honest to promise: a window the WM never adopted, a desktop
|
|
2357
|
+
* that has gone, a tree with no content leaf at all, or a leaf with no
|
|
2358
|
+
* measurable box (a layout that has not happened yet).
|
|
2359
|
+
*/
|
|
2360
|
+
_homeDockTarget(win) {
|
|
2361
|
+
const rec = [...this._windowToLeaf.values()].find((r) => r.window === win);
|
|
2362
|
+
if (!rec) return null;
|
|
2363
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
2364
|
+
if (!tree) return null;
|
|
2365
|
+
const home = rec.homeLeafId ? tree.get(rec.homeLeafId) : null;
|
|
2366
|
+
const leafId = (home && home.kind === 'leaf')
|
|
2367
|
+
? rec.homeLeafId
|
|
2368
|
+
: tree.primaryLeafId();
|
|
2369
|
+
if (!leafId) return null;
|
|
2370
|
+
const el = this.renderer.leafEl(leafId);
|
|
2371
|
+
if (!el) return null;
|
|
2372
|
+
const rect = el.getBoundingClientRect();
|
|
2373
|
+
if (!rect.width || !rect.height) return null;
|
|
2374
|
+
return { leafId, rect };
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
_snapCommit(probe, win) {
|
|
2378
|
+
if (!probe) return false;
|
|
2379
|
+
const rec = [...this._windowToLeaf.entries()]
|
|
2380
|
+
.find(([, r]) => r.window === win);
|
|
2381
|
+
if (!rec) return false;
|
|
2382
|
+
const [winId, record] = rec;
|
|
2383
|
+
const { leafId, desktopIdx, side } = probe;
|
|
2384
|
+
|
|
2385
|
+
// R14. THE TOP EDGE OF THE WINDOW'S OWN PANE DOCKS IT HOME.
|
|
2386
|
+
//
|
|
2387
|
+
// It goes through `bringBackWindow` rather than through
|
|
2388
|
+
// `dockWindowInto` below, and that is the point rather than a shortcut:
|
|
2389
|
+
// "back to tile" is ONE verb with one implementation, and it is the
|
|
2390
|
+
// implementation that knows a home pane which has since acquired
|
|
2391
|
+
// content must be joined as a tab instead of replaced. Naming the leaf
|
|
2392
|
+
// here and calling `dockWindowInto` would be a second copy of that
|
|
2393
|
+
// rule, and a second copy is a copy that disagrees within a release.
|
|
2394
|
+
//
|
|
2395
|
+
// IT RETURNS BEFORE `record.desktopIdx` IS REWRITTEN below, for the
|
|
2396
|
+
// reason R13's branch did: the destination was resolved from the
|
|
2397
|
+
// RECORD's own desktop by `_homeDockTarget`, so rewriting the record to
|
|
2398
|
+
// the desktop the pointer is on would send `bringBackWindow` looking
|
|
2399
|
+
// for the home leaf in a tree that does not contain it — and it would
|
|
2400
|
+
// then fall through to that desktop's primary tile, which is not the
|
|
2401
|
+
// rectangle the preview drew.
|
|
2402
|
+
//
|
|
2403
|
+
// WHAT USED TO BE HERE: `toggleMaximize({ claimable: false })`, the
|
|
2404
|
+
// geometric maximise, with `{claimable: false}` load-bearing precisely
|
|
2405
|
+
// so the call would NOT reach `bringBackWindow`. The ruling inverts
|
|
2406
|
+
// that — the dock is now the whole intent — so the escape hatch is
|
|
2407
|
+
// gone from this file altogether. It stays on `ManagedWindow` as a
|
|
2408
|
+
// public API for a consumer that genuinely wants a rectangle (a dialog
|
|
2409
|
+
// has no tile to go back to, so `openModal`'s maximise is exactly
|
|
2410
|
+
// that); nothing in the tiling layer calls it any more.
|
|
2411
|
+
if (probe.mode === 'home') {
|
|
2412
|
+
// NOT TAKEN UNLESS IT REALLY WAS — the same rule the dock below
|
|
2413
|
+
// follows, and reachable here: `bringBackWindow` returns false for
|
|
2414
|
+
// a window whose record has gone between the probe and the release.
|
|
2415
|
+
// Claiming the drop after it did nothing would leave the preview
|
|
2416
|
+
// painted over the page with nothing left to remove it, and the
|
|
2417
|
+
// window abandoned wherever the pointer let go; returning false
|
|
2418
|
+
// hands the release back to `_endDragEscape`, which puts the window
|
|
2419
|
+
// back in its pane.
|
|
2420
|
+
if (!this.bringBackWindow(winId)) return false;
|
|
2421
|
+
win.clearSnapPreview();
|
|
2422
|
+
return true;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// The window's home follows the drop. Without this the dock is applied
|
|
2426
|
+
// to the tree of the desktop the window was PROMOTED from, and the tile
|
|
2427
|
+
// the user aimed at is on the one they are looking at.
|
|
2428
|
+
if (typeof desktopIdx === 'number') record.desktopIdx = desktopIdx;
|
|
2429
|
+
|
|
2430
|
+
// THE DRAG ALREADY ANSWERED, so there is nothing to ask — the probe
|
|
2431
|
+
// decided between the outcomes on the way in, and the preview has
|
|
2432
|
+
// been drawing that answer for as long as the pointer has been there. A
|
|
2433
|
+
// menu on release would be asking a question the user has already spent
|
|
2434
|
+
// the whole drag answering.
|
|
2435
|
+
//
|
|
2436
|
+
// `dir` and `before` are read only by the split branch; a tab or a fill
|
|
2437
|
+
// ignores them, which is why they can be computed unconditionally from
|
|
2438
|
+
// a `side` that is null for both.
|
|
2439
|
+
const docked = this.dockWindowInto(winId, {
|
|
2440
|
+
leafId,
|
|
2441
|
+
mode: probe.mode || 'split',
|
|
2442
|
+
dir: (side === 'left' || side === 'right') ? 'h' : 'v',
|
|
2443
|
+
before: (side === 'left' || side === 'top'),
|
|
2444
|
+
});
|
|
2445
|
+
// NOT TAKEN unless it really was. A truthy return tells the window the
|
|
2446
|
+
// controller owns it AND owns the preview, so returning true after a
|
|
2447
|
+
// failed dock would leave a rectangle painted over the page with
|
|
2448
|
+
// nothing left to remove it, and a window sitting wherever the drag
|
|
2449
|
+
// abandoned it instead of back in its pane.
|
|
2450
|
+
if (!docked) return false;
|
|
2451
|
+
win.clearSnapPreview();
|
|
2452
|
+
return true;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* Put a floating window's content back into the tree at a NAMED place.
|
|
2457
|
+
*
|
|
2458
|
+
* `bringBackWindow` is this with `{ mode: 'tab' }` against the primary tile
|
|
2459
|
+
* — the answer when the user pressed a button in the window's own chrome
|
|
2460
|
+
* and named no destination. A drop names one.
|
|
2461
|
+
*
|
|
2462
|
+
* The window is CLOSED to do it, exactly as a demote is: the content
|
|
2463
|
+
* factory re-mounts inside the tile, and a factory that must not lose live
|
|
2464
|
+
* state across that boundary is the embedder's problem to solve (it is why
|
|
2465
|
+
* the registry is keyed on (kind, props) rather than on a DOM node).
|
|
2466
|
+
*
|
|
2467
|
+
* @param {string} windowId
|
|
2468
|
+
* @param {{leafId: string, mode: 'fill'|'tab'|'split', dir?: 'h'|'v',
|
|
2469
|
+
* before?: boolean}} target
|
|
2470
|
+
*/
|
|
2471
|
+
dockWindowInto(windowId, target) {
|
|
2472
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
2473
|
+
if (!rec || !target?.leafId) return false;
|
|
2474
|
+
rec._dock = { ...target };
|
|
2475
|
+
rec._demoting = true;
|
|
2476
|
+
try { rec.window.close({ force: true }); }
|
|
2477
|
+
catch (err) { console.warn('[wm] dock: close failed', err); return false; }
|
|
2478
|
+
return true;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/**
|
|
2482
|
+
* Move a managed window to another desktop.
|
|
2483
|
+
*
|
|
2484
|
+
* ══ IT USED TO REWRITE ONE INTEGER, AND THAT MOVED NOTHING ═════════
|
|
2485
|
+
*
|
|
2486
|
+
* The sentence that stood here — *"the window itself stays on screen
|
|
2487
|
+
* (managed windows are global)"* — was true of a window floating over the
|
|
2488
|
+
* root and false of every window this WM promotes under `promoteInPlace`,
|
|
2489
|
+
* which is CONTAINED IN A TILE (C21). Rewriting `desktopIdx` left such a
|
|
2490
|
+
* window standing in the pane it was already in, on the page the user was
|
|
2491
|
+
* already looking at: *Move to desktop Views* appeared to do nothing at
|
|
2492
|
+
* all. Then *Back to tile* resolved against the new desktop's tree and
|
|
2493
|
+
* docked the content onto a page nobody was watching, so the window
|
|
2494
|
+
* vanished here and its table turned up over there.
|
|
2495
|
+
*
|
|
2496
|
+
* Three things move it for real. The index, so every later resolution
|
|
2497
|
+
* agrees. The HOME LEAF, re-pointed at a ground that exists in the
|
|
2498
|
+
* destination — without it the record names a leaf of the tree it just
|
|
2499
|
+
* left, and `_rehomeContainedWindows` would either skip it forever or
|
|
2500
|
+
* repair it to a pane on the wrong page. And a render, which is where the
|
|
2501
|
+
* element is taken off the page the window has left (or parented into its
|
|
2502
|
+
* new ground, when the destination is the desktop on screen).
|
|
2503
|
+
*
|
|
2504
|
+
* An ADOPTED window (`homeContainer`) keeps its own resolution: the
|
|
2505
|
+
* embedder owns the box it stands in, and re-pointing a leaf id it does not
|
|
2506
|
+
* read would be a change with no effect wearing the look of one.
|
|
2507
|
+
*/
|
|
692
2508
|
moveWindowToDesktop(windowId, targetIdx) {
|
|
693
2509
|
const rec = this._windowToLeaf.get(windowId);
|
|
694
2510
|
if (!rec) return;
|
|
695
2511
|
if (rec.desktopIdx === targetIdx) return;
|
|
696
2512
|
this.desktops.ensureCount(targetIdx + 1);
|
|
697
2513
|
rec.desktopIdx = targetIdx;
|
|
2514
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
2515
|
+
const target = this.desktops.desktops[targetIdx]?.tree;
|
|
2516
|
+
// `|| null`, NEVER `|| rec.homeLeafId`. Falling back to the id it
|
|
2517
|
+
// already had keeps a leaf of the tree the window is LEAVING, and
|
|
2518
|
+
// `_rehomeContainedWindows` would then find that id perfectly alive
|
|
2519
|
+
// in the wrong tree and never repair it. Null means "not contained
|
|
2520
|
+
// anywhere", which the pass understands and which the repair below
|
|
2521
|
+
// fixes on the first render of the destination.
|
|
2522
|
+
rec.homeLeafId = target?.primaryLeafId() || null;
|
|
2523
|
+
}
|
|
2524
|
+
this.renderer.render();
|
|
698
2525
|
this._persist();
|
|
699
2526
|
this._notifyChange('window-moved');
|
|
700
2527
|
}
|
|
701
2528
|
|
|
2529
|
+
/**
|
|
2530
|
+
* R8. Put a floated pane's tabs back into a leaf — ALL of them, in the
|
|
2531
|
+
* order they had, with the one that was showing still showing.
|
|
2532
|
+
*
|
|
2533
|
+
* Every dock goes through here, and that is the point: `bringBackWindow`,
|
|
2534
|
+
* a drop on a tile's body, a drop on an edge and the fall-through when the
|
|
2535
|
+
* named destination vanished are four routes to one question — *where do
|
|
2536
|
+
* these tabs go* — and four copies of the answer would disagree about the
|
|
2537
|
+
* third one within a release. A window promoted before R8 (or by
|
|
2538
|
+
* `_navigateWindow`, which never had tabs) carries no list, so `original`
|
|
2539
|
+
* is the fallback and the single-tab path reduces to exactly what this
|
|
2540
|
+
* replaced.
|
|
2541
|
+
*
|
|
2542
|
+
* `replace` is the difference between filling a leaf and joining one: a
|
|
2543
|
+
* fresh split leaf and a `fill` drop want the first tab to BECOME the
|
|
2544
|
+
* leaf's content, while a `tab` drop and "back to tile" append beside what
|
|
2545
|
+
* is already there.
|
|
2546
|
+
*/
|
|
2547
|
+
_restoreTabs(tree, leafId, rec, { replace }) {
|
|
2548
|
+
if (!leafId) return false;
|
|
2549
|
+
const tabs = (Array.isArray(rec.tabs) && rec.tabs.length)
|
|
2550
|
+
? rec.tabs
|
|
2551
|
+
: [{ kind: rec.original.kind, props: rec.original.props,
|
|
2552
|
+
title: rec.original.title }];
|
|
2553
|
+
const active = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
|
|
2554
|
+
let firstIdx = -1;
|
|
2555
|
+
tabs.forEach((tab, i) => {
|
|
2556
|
+
const content = { kind: tab.kind, props: tab.props || {} };
|
|
2557
|
+
const title = tab.title || tab.kind || '';
|
|
2558
|
+
if (i === 0 && replace) {
|
|
2559
|
+
tree.setLeafContent(leafId, content, title);
|
|
2560
|
+
firstIdx = 0;
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
// `appendLeafTab` returns -1 for a panel leaf, which is the one
|
|
2564
|
+
// leaf that never grows tabs. Nothing docks onto a panel — the
|
|
2565
|
+
// probe refuses one and `bringBackWindow` targets the primary tile
|
|
2566
|
+
// — so this is a guard, not a path.
|
|
2567
|
+
const at = tree.appendLeafTab(leafId, content, title);
|
|
2568
|
+
if (at >= 0 && firstIdx < 0) firstIdx = at;
|
|
2569
|
+
});
|
|
2570
|
+
if (firstIdx < 0) return false;
|
|
2571
|
+
tree.setActiveLeafTab(leafId, firstIdx + active);
|
|
2572
|
+
tree.focus(leafId);
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
702
2576
|
_onManagedWindowClosed(winId, mountInfo) {
|
|
703
2577
|
const rec = this._windowToLeaf.get(winId);
|
|
704
2578
|
this._windowToLeaf.delete(winId);
|
|
705
2579
|
if (!rec) return;
|
|
706
|
-
|
|
2580
|
+
// R8. `rec.mountInfo` IS THE LIVE ONE. The window's mount changes when
|
|
2581
|
+
// its tab changes (`_mountWindowTab`) and when its content is replaced
|
|
2582
|
+
// in place (`openInWindow`), so the closure `_promote` captured at
|
|
2583
|
+
// build time names a mount that was torn down long ago — destroying it
|
|
2584
|
+
// twice while the CURRENT one is never destroyed at all, which is a
|
|
2585
|
+
// leaked page module with a `window` listener on it. `_navigateWindow`
|
|
2586
|
+
// still passes its own and it is the same object, so the argument
|
|
2587
|
+
// stays for the callers that have nothing else.
|
|
2588
|
+
try { (rec.mountInfo || mountInfo)?.destroy?.(); } catch {}
|
|
2589
|
+
try { rec.strip?.dispose(); } catch { /* never mounted, or already gone */ }
|
|
707
2590
|
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
708
2591
|
if (!tree) return;
|
|
709
2592
|
|
|
710
|
-
if (rec._demoting) {
|
|
2593
|
+
if (rec._demoting && rec._dock) {
|
|
2594
|
+
// C15. A DROP named its destination, so none of the primary-tile
|
|
2595
|
+
// reasoning below applies. A destination that vanished between the
|
|
2596
|
+
// release and the close (the tile was closed by a keystroke while a
|
|
2597
|
+
// choice menu was open) falls through to the primary tile rather
|
|
2598
|
+
// than dropping the content on the floor.
|
|
2599
|
+
const target = tree.get(rec._dock.leafId);
|
|
2600
|
+
if (target && target.kind === 'leaf') {
|
|
2601
|
+
const { mode, dir, before } = rec._dock;
|
|
2602
|
+
if (mode === 'tab') {
|
|
2603
|
+
this._restoreTabs(tree, rec._dock.leafId, rec, { replace: false });
|
|
2604
|
+
} else if (mode === 'split') {
|
|
2605
|
+
const newId = tree.split(rec._dock.leafId, dir);
|
|
2606
|
+
if (newId) {
|
|
2607
|
+
this._restoreTabs(tree, newId, rec, { replace: true });
|
|
2608
|
+
// THE PREVIEW PROMISED HALF OF THAT TILE. `TileTree.split`
|
|
2609
|
+
// delivers it only when it has to WRAP; when the parent
|
|
2610
|
+
// split already runs this direction it INSERTS a sibling
|
|
2611
|
+
// and gives it the average of the row's existing sizes
|
|
2612
|
+
// (`tile_tree.js`, case 2), so a drop into a three-pane
|
|
2613
|
+
// row hands the newcomer a third and shrinks everyone
|
|
2614
|
+
// else. Halving the source's own weight and giving the
|
|
2615
|
+
// other half to the new leaf makes the two panes split
|
|
2616
|
+
// the space the source had — which is what was drawn.
|
|
2617
|
+
_halveInto(tree, rec._dock.leafId, newId);
|
|
2618
|
+
// `split` always appends the new leaf AFTER its source.
|
|
2619
|
+
// A drop on the LEFT or TOP edge means the window
|
|
2620
|
+
// belongs on that side, so the two swap places — the
|
|
2621
|
+
// preview drew the left half and the left half is where
|
|
2622
|
+
// it must land.
|
|
2623
|
+
if (before) _swapSiblings(tree, rec._dock.leafId, newId);
|
|
2624
|
+
tree.focus(newId);
|
|
2625
|
+
}
|
|
2626
|
+
} else {
|
|
2627
|
+
this._restoreTabs(tree, rec._dock.leafId, rec, { replace: true });
|
|
2628
|
+
}
|
|
2629
|
+
} else {
|
|
2630
|
+
this._restoreTabs(tree, tree.primaryLeafId(), rec, { replace: false });
|
|
2631
|
+
}
|
|
2632
|
+
} else if (rec._demoting) {
|
|
711
2633
|
// "Back to tile": the source tile was closed when the window was
|
|
712
2634
|
// promoted, so dock the (latest) content into the desktop's
|
|
713
2635
|
// primary tile as a new tab, spawning a tile when it has none.
|
|
@@ -715,10 +2637,7 @@ export class WindowManager {
|
|
|
715
2637
|
let spawned = false;
|
|
716
2638
|
if (!pid) { pid = this._spawnContentLeaf(tree); spawned = true; }
|
|
717
2639
|
if (pid) {
|
|
718
|
-
|
|
719
|
-
{ kind: rec.original.kind, props: rec.original.props },
|
|
720
|
-
rec.original.title);
|
|
721
|
-
tree.focus(pid);
|
|
2640
|
+
this._restoreTabs(tree, pid, rec, { replace: false });
|
|
722
2641
|
if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
723
2642
|
}
|
|
724
2643
|
}
|
|
@@ -730,24 +2649,26 @@ export class WindowManager {
|
|
|
730
2649
|
this._notifyChange(rec._demoting ? 'window-demoted' : 'window-closed');
|
|
731
2650
|
}
|
|
732
2651
|
|
|
733
|
-
/** Post-show DOM hook:
|
|
734
|
-
*
|
|
2652
|
+
/** Post-show DOM hook: wire a right-click context menu on the topbar.
|
|
2653
|
+
*
|
|
2654
|
+
* R7. IT USED TO INJECT A BUTTON HERE, and that is the whole of what
|
|
2655
|
+
* changed. "Back to tile" was a fourth button squeezed left of Close,
|
|
2656
|
+
* built by reaching into four of ManagedWindow's internal class names —
|
|
2657
|
+
* the coupling C6 exists to avoid — and it sat next to a MAXIMIZE button
|
|
2658
|
+
* that did the one thing a window lifted out of a tile has no use for.
|
|
2659
|
+
* Now the maximize button IS "back to tile" (`onMaximize`, passed where
|
|
2660
|
+
* the window is built), so the verb has one control instead of two and
|
|
2661
|
+
* this hook has no markup of its own to keep in step.
|
|
2662
|
+
*
|
|
2663
|
+
* Gone with it: the rule that hid MINIMIZE while the window was maximised.
|
|
2664
|
+
* It existed because minimising a full-screen window strands it — nothing
|
|
2665
|
+
* on screen points at it any more — and a window that cannot maximise
|
|
2666
|
+
* cannot be in that state at all. `managed-window-maximized` (C16) still
|
|
2667
|
+
* fires for everyone else, and `--suppressed` is still styled for the next
|
|
2668
|
+
* consumer that needs to hide one of these. */
|
|
735
2669
|
_decorateManagedWindow(win, winId) {
|
|
736
2670
|
const topbar = win.element?.querySelector?.('.twm-managed-window__topbar');
|
|
737
|
-
|
|
738
|
-
if (!buttons) return;
|
|
739
|
-
const backBtn = document.createElement('button');
|
|
740
|
-
backBtn.type = 'button';
|
|
741
|
-
backBtn.className = 'twm-managed-window__btn managed-window__btn--demote';
|
|
742
|
-
backBtn.title = 'Back to tile';
|
|
743
|
-
backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
|
|
744
|
-
backBtn.addEventListener('click', (e) => {
|
|
745
|
-
e.stopPropagation();
|
|
746
|
-
this.bringBackWindow(winId);
|
|
747
|
-
});
|
|
748
|
-
const closeBtn = buttons.querySelector('.twm-managed-window__btn--close');
|
|
749
|
-
if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
|
|
750
|
-
else buttons.appendChild(backBtn);
|
|
2671
|
+
if (!topbar) return;
|
|
751
2672
|
|
|
752
2673
|
topbar.addEventListener('contextmenu', (e) => {
|
|
753
2674
|
e.preventDefault();
|
|
@@ -781,6 +2702,151 @@ export class WindowManager {
|
|
|
781
2702
|
});
|
|
782
2703
|
}
|
|
783
2704
|
|
|
2705
|
+
// ══ R10. Adjacent start tiles are one start tile ═══════════════════
|
|
2706
|
+
/**
|
|
2707
|
+
* Merge every run of side-by-side START TILES into one.
|
|
2708
|
+
*
|
|
2709
|
+
* A start tile is a leaf holding the taxonomy ROOT — the pane a fresh
|
|
2710
|
+
* desktop opens with, and the pane `_seedHome` puts back when a tile is
|
|
2711
|
+
* emptied. It is not a document: it is the ground, the empty canvas, the
|
|
2712
|
+
* "nothing is open here" surface. So two of them side by side are ONE
|
|
2713
|
+
* surface with a splitter drawn through it for no reason, and the splitter
|
|
2714
|
+
* is worse than decoration — it offers to resize a boundary between two
|
|
2715
|
+
* things that are the same thing.
|
|
2716
|
+
*
|
|
2717
|
+
* This is deliberately NOT run on every tree change, and the reason is
|
|
2718
|
+
* `split()`: splitting a start tile seeds the new pane with the root kind
|
|
2719
|
+
* too (the never-empty-tile invariant), so a merge on every mutation would
|
|
2720
|
+
* undo an Alt+H the instant it happened. It runs where the product owner
|
|
2721
|
+
* put it — *"when a maximized (tiled) panel is window-ized, all adjacent
|
|
2722
|
+
* non-panel (start tile) tiles get merged to one"* — and is public so an
|
|
2723
|
+
* embedder that empties a pane its own way can ask for the same tidy-up.
|
|
2724
|
+
*
|
|
2725
|
+
* THREE THINGS ARE NEVER MERGED, and each one is a way to lose work:
|
|
2726
|
+
*
|
|
2727
|
+
* - `panel:*` leaves. They are chrome, not content; the navigator is not
|
|
2728
|
+
* a start tile and a panel BETWEEN two start tiles means those two are
|
|
2729
|
+
* not adjacent.
|
|
2730
|
+
* - A start tile with WINDOWS STANDING ON IT. The whole point of the
|
|
2731
|
+
* surface is that things float on it, and closing the leaf takes its
|
|
2732
|
+
* ground — and every window clamped to it — out of the document. When
|
|
2733
|
+
* one of a pair is occupied the other merges INTO it; when both are,
|
|
2734
|
+
* neither moves.
|
|
2735
|
+
* - A start tile holding tabs, live or archived. `leaf.content.kind`
|
|
2736
|
+
* names the ACTIVE tab only, and a pane whose other tabs are tables, or
|
|
2737
|
+
* whose `pageTabs` archive holds the three tables a rail click put
|
|
2738
|
+
* there, is a pane with work in it wearing a start tile's face.
|
|
2739
|
+
*
|
|
2740
|
+
* @param {TileTree} tree
|
|
2741
|
+
* @param {string|null} preferLeafId the leaf to keep when a run is
|
|
2742
|
+
* otherwise a free choice — the pane the caller just emptied, so the
|
|
2743
|
+
* merged surface is the one the user is looking at.
|
|
2744
|
+
* @returns {number} how many leaves were absorbed.
|
|
2745
|
+
*/
|
|
2746
|
+
_mergeStartTiles(tree, preferLeafId = null) {
|
|
2747
|
+
if (!tree) return 0;
|
|
2748
|
+
let absorbed = 0;
|
|
2749
|
+
// Pairwise, restarting after each close. `TileTree.close` splices the
|
|
2750
|
+
// parent's children AND may collapse the parent into ITS parent, so
|
|
2751
|
+
// every index in flight is stale the moment one leaf goes; restarting
|
|
2752
|
+
// is the cheap way to be right rather than the clever way to be wrong.
|
|
2753
|
+
// It terminates because each pass removes exactly one leaf.
|
|
2754
|
+
for (;;) {
|
|
2755
|
+
const pair = this._nextMergeablePair(tree, preferLeafId);
|
|
2756
|
+
if (!pair) break;
|
|
2757
|
+
const { split, keepIdx, dropIdx, keepId, dropId } = pair;
|
|
2758
|
+
// THE SPACE GOES WITH IT. `close` splices the size out of the row,
|
|
2759
|
+
// which hands it to every sibling in proportion; the merged pane is
|
|
2760
|
+
// supposed to occupy what the two of them occupied, so the keeper
|
|
2761
|
+
// takes it first.
|
|
2762
|
+
split.sizes[keepIdx] = (split.sizes[keepIdx] || 1) + (split.sizes[dropIdx] || 1);
|
|
2763
|
+
const hadFocus = tree.focusedLeafId === dropId;
|
|
2764
|
+
tree.close(dropId);
|
|
2765
|
+
// A LOOP THAT CANNOT END IS WORSE THAN A SPLITTER NOBODY WANTED.
|
|
2766
|
+
// The loop's termination argument is "each pass removes one leaf";
|
|
2767
|
+
// if `close` ever declines — a leaf that is not a leaf, a tree
|
|
2768
|
+
// mutated underneath us — that argument fails silently and the tab
|
|
2769
|
+
// freezes. Stopping is the correct answer to a merge that did not
|
|
2770
|
+
// happen.
|
|
2771
|
+
if (tree.nodes.has(dropId)) break;
|
|
2772
|
+
if (hadFocus) tree.focus(keepId);
|
|
2773
|
+
absorbed += 1;
|
|
2774
|
+
}
|
|
2775
|
+
return absorbed;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/** The first two adjacent start tiles that may be merged, and which of
|
|
2779
|
+
* them survives. Null when there are none. */
|
|
2780
|
+
_nextMergeablePair(tree, preferLeafId) {
|
|
2781
|
+
for (const node of [...tree.nodes.values()]) {
|
|
2782
|
+
if (node.kind !== 'split') continue;
|
|
2783
|
+
// The snapshot above can name a split a previous pass collapsed.
|
|
2784
|
+
if (!tree.nodes.has(node.id)) continue;
|
|
2785
|
+
for (let i = 0; i < node.children.length - 1; i += 1) {
|
|
2786
|
+
const a = tree.get(node.children[i]);
|
|
2787
|
+
const b = tree.get(node.children[i + 1]);
|
|
2788
|
+
if (!this._isStartTile(a) || !this._isStartTile(b)) continue;
|
|
2789
|
+
const aHolds = this._paneHoldsWindows(a.id);
|
|
2790
|
+
const bHolds = this._paneHoldsWindows(b.id);
|
|
2791
|
+
if (aHolds && bHolds) continue;
|
|
2792
|
+
let keepIdx = i;
|
|
2793
|
+
if (bHolds) keepIdx = i + 1;
|
|
2794
|
+
else if (!aHolds && node.children[i + 1] === preferLeafId) keepIdx = i + 1;
|
|
2795
|
+
const dropIdx = keepIdx === i ? i + 1 : i;
|
|
2796
|
+
return { split: node, keepIdx, dropIdx,
|
|
2797
|
+
keepId: node.children[keepIdx], dropId: node.children[dropIdx] };
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
return null;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/** Is this leaf the empty ground and nothing else? See the three
|
|
2804
|
+
* exclusions in `_mergeStartTiles`. */
|
|
2805
|
+
_isStartTile(leaf) {
|
|
2806
|
+
if (!leaf || leaf.kind !== 'leaf') return false;
|
|
2807
|
+
const kind = leaf.content?.kind;
|
|
2808
|
+
if (!kind || kind !== this.taxonomy.root) return false;
|
|
2809
|
+
if (PANEL_KINDS.has(kind) || kind === PLACEHOLDER_KIND) return false;
|
|
2810
|
+
if ((Array.isArray(leaf.tabs) ? leaf.tabs.length : 0) > 1) return false;
|
|
2811
|
+
for (const page of Object.values(leaf.pageTabs || {})) {
|
|
2812
|
+
if (Array.isArray(page?.tabs) && page.tabs.length) return false;
|
|
2813
|
+
}
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/**
|
|
2818
|
+
* Does anything float on this pane?
|
|
2819
|
+
*
|
|
2820
|
+
* Two sources, because there are two kinds of window and the WM only knows
|
|
2821
|
+
* about one of them. `homeLeafId` is set for a window this WM contained in
|
|
2822
|
+
* its own pane (C21); an EMBEDDER's windows — a canvas pane that opens its
|
|
2823
|
+
* own `ManagedWindow` against the pane's ground — are not in
|
|
2824
|
+
* `_windowToLeaf` at all, and the only honest way to see them is to look.
|
|
2825
|
+
* The DOM answer covers both, and covers a window whose record has been
|
|
2826
|
+
* dropped but whose element is still standing.
|
|
2827
|
+
*/
|
|
2828
|
+
_paneHoldsWindows(leafId) {
|
|
2829
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
2830
|
+
if (rec.homeLeafId === leafId) return true;
|
|
2831
|
+
}
|
|
2832
|
+
const el = this.renderer.leafEl?.(leafId);
|
|
2833
|
+
return !!el?.querySelector?.('.twm-managed-window');
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/** R10, as a verb an embedder can use. Merges, then repaints and persists
|
|
2837
|
+
* — the promote path calls `_mergeStartTiles` directly because it is
|
|
2838
|
+
* already going to do all three. */
|
|
2839
|
+
mergeStartTiles(preferLeafId = null) {
|
|
2840
|
+
const tree = this._tree();
|
|
2841
|
+
const absorbed = this._mergeStartTiles(tree, preferLeafId);
|
|
2842
|
+
if (!absorbed) return 0;
|
|
2843
|
+
this._canonicalize(tree, this.desktops.active());
|
|
2844
|
+
this.renderer.render();
|
|
2845
|
+
this._persist();
|
|
2846
|
+
this._notifyChange('start-tiles-merged');
|
|
2847
|
+
return absorbed;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
784
2850
|
// ── Panel-tiles (left nav / right / bottom) ─────────────────────
|
|
785
2851
|
/** Panels are virtual: they live in the active desktop's tree as
|
|
786
2852
|
* leaves with content kinds 'panel:left', 'panel:right',
|
|
@@ -981,12 +3047,60 @@ export class WindowManager {
|
|
|
981
3047
|
this._leafTabAction(leafId, 'switch', { idx: next });
|
|
982
3048
|
}
|
|
983
3049
|
|
|
3050
|
+
/**
|
|
3051
|
+
* Remove a desktop — AND RE-INDEX THE WINDOWS, which is the half that was
|
|
3052
|
+
* missing.
|
|
3053
|
+
*
|
|
3054
|
+
* `desktopIdx` on a window record is an ARRAY INDEX into `desktops`, so a
|
|
3055
|
+
* splice silently re-points every record above the removed one at its
|
|
3056
|
+
* neighbour. Nothing threw and nothing looked wrong: the window kept
|
|
3057
|
+
* floating, and the next *Back to tile* resolved `rec._dock` against the
|
|
3058
|
+
* WRONG TREE. `_onManagedWindowClosed` reads `desktops[rec.desktopIdx]`,
|
|
3059
|
+
* finds a tree that never held this window, and its `if (!tree) return`
|
|
3060
|
+
* closes the window and drops the content on the floor — staged edits
|
|
3061
|
+
* included, with no error and nothing on screen to say a table was lost.
|
|
3062
|
+
*
|
|
3063
|
+
* Windows homed on the desktop being removed do not die with it. The
|
|
3064
|
+
* ruling is that a tile operation may move a window and never destroy it,
|
|
3065
|
+
* and removing a desktop is the largest tile operation there is: they come
|
|
3066
|
+
* across to the desktop that ends up active, re-homed onto its ground by
|
|
3067
|
+
* `_rehomeContainedWindows` on the render below.
|
|
3068
|
+
*/
|
|
984
3069
|
removeDesktop(idx) {
|
|
985
3070
|
const m = this.desktops;
|
|
986
3071
|
if (m.desktops.length <= 1) return false;
|
|
987
3072
|
if (idx < 0 || idx >= m.desktops.length) return false;
|
|
988
3073
|
m.desktops.splice(idx, 1);
|
|
3074
|
+
// THE SPLICE MOVES THE ACTIVE DESKTOP TOO, and the clamp below never
|
|
3075
|
+
// said so. Removing a desktop BELOW the one you are on shifts it down
|
|
3076
|
+
// by one, so an unchanged `activeIdx` then names its neighbour: the
|
|
3077
|
+
// desktop you were working in is replaced on screen by the next one
|
|
3078
|
+
// along, the bar highlights the wrong button, and the wrong index is
|
|
3079
|
+
// persisted. The clamp only ever caught removing the LAST desktop.
|
|
3080
|
+
// Pre-existing, and load-bearing for the loop below, which reads
|
|
3081
|
+
// `activeIdx` to decide where the orphaned windows go.
|
|
3082
|
+
if (idx < m.activeIdx) m.activeIdx -= 1;
|
|
989
3083
|
if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
|
|
3084
|
+
// AFTER the splice and AFTER `activeIdx` is repaired, so the survivors
|
|
3085
|
+
// resolve against the array the rest of this method uses.
|
|
3086
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
3087
|
+
if (rec.desktopIdx === idx) {
|
|
3088
|
+
rec.desktopIdx = m.activeIdx;
|
|
3089
|
+
// ONLY A WINDOW THAT WAS ALREADY CONTAINED, which is what
|
|
3090
|
+
// `rec.homeLeafId` says and what `moveWindowToDesktop`'s guard
|
|
3091
|
+
// tests. `!rec.homeContainer` alone also catches a FREE-FLOATING
|
|
3092
|
+
// window — `_navigateWindow`'s, which has neither field — and
|
|
3093
|
+
// handing one a `homeLeafId` silently converts it into a
|
|
3094
|
+
// pane-contained window: it jumps into a tile, can no longer be
|
|
3095
|
+
// dragged outside it, and is then removed from the document by
|
|
3096
|
+
// the desktop rule the first time you switch pages.
|
|
3097
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
3098
|
+
rec.homeLeafId = m.active().tree.primaryLeafId() || null;
|
|
3099
|
+
}
|
|
3100
|
+
} else if (rec.desktopIdx > idx) {
|
|
3101
|
+
rec.desktopIdx -= 1;
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
990
3104
|
this.renderer.tree = m.active().tree;
|
|
991
3105
|
this.renderer.render();
|
|
992
3106
|
this._persist();
|
|
@@ -998,6 +3112,13 @@ export class WindowManager {
|
|
|
998
3112
|
const tree = this._tree();
|
|
999
3113
|
const focused = tree.focused();
|
|
1000
3114
|
if (!focused || !focused.content) return;
|
|
3115
|
+
// C20. IT DESTROYS A LEAF, SO IT ANSWERS TO THE SAME VETO `closeFocused`
|
|
3116
|
+
// does. A move is not a close from the pane's point of view, but it is
|
|
3117
|
+
// exactly a close from the PAGE's: this was the fourth door out of a
|
|
3118
|
+
// content veto, and the one nobody thought to look at because its label
|
|
3119
|
+
// says "Move".
|
|
3120
|
+
const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
|
|
3121
|
+
if (closeChrome === false || closeChrome?.disabled === true) return;
|
|
1001
3122
|
const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
|
|
1002
3123
|
this.desktops.ensureCount(idx + 1);
|
|
1003
3124
|
const target = this.desktops.desktops[idx];
|
|
@@ -1008,6 +3129,16 @@ export class WindowManager {
|
|
|
1008
3129
|
// Remove from current desktop.
|
|
1009
3130
|
tree.close(focused.id);
|
|
1010
3131
|
if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
|
|
3132
|
+
// THE SAME NEVER-EMPTY-CONTENT INVARIANT `closeFocused` KEEPS, and it
|
|
3133
|
+
// was missing here. `!tree.rootId` above only catches a tree with
|
|
3134
|
+
// nothing left at all; with a panel open the root survives and the page
|
|
3135
|
+
// is left showing chrome and no ground — so the windows that were
|
|
3136
|
+
// standing on the moved pane had nowhere to be re-homed to.
|
|
3137
|
+
if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
|
|
3138
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
3139
|
+
if (spawned) this._seedHome(tree, spawned);
|
|
3140
|
+
}
|
|
3141
|
+
this._canonicalize(tree, this.desktops.active());
|
|
1011
3142
|
this.renderer.render();
|
|
1012
3143
|
this._persist();
|
|
1013
3144
|
this._notifyChange();
|
|
@@ -1069,6 +3200,24 @@ export class WindowManager {
|
|
|
1069
3200
|
this._showTabContextMenu(leafId, data.idx, data.x, data.y);
|
|
1070
3201
|
return;
|
|
1071
3202
|
}
|
|
3203
|
+
if (action === 'to-window') {
|
|
3204
|
+
// R9. `floatTabAsWindow` renders, persists and notifies on its own
|
|
3205
|
+
// — it is the same promote path the pane's chrome uses — so there
|
|
3206
|
+
// is deliberately nothing after it here.
|
|
3207
|
+
this.floatTabAsWindow(leafId, data.idx);
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
if (action === 'drop-into') {
|
|
3211
|
+
// C33. The tab-drop's one door into the tree. Fired against the
|
|
3212
|
+
// SOURCE leaf, because `_leafTabAction`'s first argument is always
|
|
3213
|
+
// the leaf whose tab is being acted on — the destination travels in
|
|
3214
|
+
// `data.target`, which is a `tabDropProbe` answer.
|
|
3215
|
+
//
|
|
3216
|
+
// Like `to-window` above, `moveTabInto` renders, persists and
|
|
3217
|
+
// notifies on its own, so there is deliberately nothing after it.
|
|
3218
|
+
this.moveTabInto(leafId, data.idx, data.target);
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
1072
3221
|
if (action === 'close-others') {
|
|
1073
3222
|
tree.closeOtherTabs(leafId, data.idx);
|
|
1074
3223
|
this.renderer.render();
|
|
@@ -1130,6 +3279,11 @@ export class WindowManager {
|
|
|
1130
3279
|
* `opts.transient` — the appended tab is not persisted/restored
|
|
1131
3280
|
* (e.g. an add-row form). Only meaningful with
|
|
1132
3281
|
* `newTab:true`.
|
|
3282
|
+
* `opts.background` — with `newTab`, append the tab WITHOUT switching to
|
|
3283
|
+
* it or focusing its tile. "Open in a background tab"
|
|
3284
|
+
* means the page you are reading stays in front;
|
|
3285
|
+
* without it the tab arrives and takes the screen,
|
|
3286
|
+
* which is what an ordinary click already does.
|
|
1133
3287
|
*
|
|
1134
3288
|
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
1135
3289
|
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
@@ -1140,6 +3294,8 @@ export class WindowManager {
|
|
|
1140
3294
|
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
1141
3295
|
navigate(kind, props = {}, opts = {}) {
|
|
1142
3296
|
const { ctx = null, transient = false } = opts;
|
|
3297
|
+
// Append the tab but stay where you are. Only meaningful with `newTab`.
|
|
3298
|
+
const background = !!opts.background;
|
|
1143
3299
|
// Resolve the two axes, honoring the legacy `target` alias.
|
|
1144
3300
|
let { dest = 'main', newTab = false } = opts;
|
|
1145
3301
|
if (opts.target != null) {
|
|
@@ -1165,12 +3321,12 @@ export class WindowManager {
|
|
|
1165
3321
|
if (dest === 'window') return this._navigateWindow(kind, props);
|
|
1166
3322
|
if (dest === 'main') {
|
|
1167
3323
|
return newTab
|
|
1168
|
-
? this.openInTabInPrimary(kind, props, transient)
|
|
3324
|
+
? this.openInTabInPrimary(kind, props, transient, background)
|
|
1169
3325
|
: this.openInPrimary(kind, props);
|
|
1170
3326
|
}
|
|
1171
3327
|
// dest === 'origin'
|
|
1172
3328
|
return newTab
|
|
1173
|
-
? this._navigateTab(ctx, kind, props, transient)
|
|
3329
|
+
? this._navigateTab(ctx, kind, props, transient, background)
|
|
1174
3330
|
: this._navigateAuto(ctx, kind, props);
|
|
1175
3331
|
}
|
|
1176
3332
|
|
|
@@ -1197,14 +3353,14 @@ export class WindowManager {
|
|
|
1197
3353
|
this.openInPrimary(kind, props);
|
|
1198
3354
|
}
|
|
1199
3355
|
|
|
1200
|
-
_navigateTab(ctx, kind, props, transient = false) {
|
|
3356
|
+
_navigateTab(ctx, kind, props, transient = false, background = false) {
|
|
1201
3357
|
// Windows aren't tabbed — "open in tab" inside a window just
|
|
1202
3358
|
// replaces the window's content.
|
|
1203
3359
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
1204
3360
|
this.openInWindow(ctx.windowId, kind, props);
|
|
1205
3361
|
return;
|
|
1206
3362
|
}
|
|
1207
|
-
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
3363
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient, background);
|
|
1208
3364
|
}
|
|
1209
3365
|
|
|
1210
3366
|
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
@@ -1226,8 +3382,38 @@ export class WindowManager {
|
|
|
1226
3382
|
canMaximize: true,
|
|
1227
3383
|
canResize: true,
|
|
1228
3384
|
modal: false,
|
|
3385
|
+
// C15, and this is the SECOND of the two places the WM builds a
|
|
3386
|
+
// window. Alt+N and "Open in new window" produce a window that is
|
|
3387
|
+
// every bit as dockable as a promoted one, and a window that can be
|
|
3388
|
+
// dragged onto a tile in one case and not the other is a rule
|
|
3389
|
+
// nobody can learn.
|
|
3390
|
+
snap: this.snapPromotion,
|
|
3391
|
+
snapController: this.snapPromotion ? this._snapController() : null,
|
|
3392
|
+
// R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
|
|
3393
|
+
// to one (C21) cannot be dragged a single pixel outside it, so
|
|
3394
|
+
// "drag a window from one tile to another" — the gesture all three
|
|
3395
|
+
// drop behaviours are built on — was not merely awkward, it was
|
|
3396
|
+
// invisible. For the length of a drag the window is re-parented
|
|
3397
|
+
// here, to the root every tile is inside; on release it goes back
|
|
3398
|
+
// into a pane, either the one it was dropped on or the one it came
|
|
3399
|
+
// from. Resolved per drag: the root outlives any tile, and a tile
|
|
3400
|
+
// grabbed once does not survive its own repaint.
|
|
3401
|
+
dragHost: () => this.rootEl,
|
|
3402
|
+
dragBounds: () => this._tileBounds(),
|
|
3403
|
+
// R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
|
|
3404
|
+
// tree; the useful thing to do with it is put it back, and filling
|
|
3405
|
+
// the screen with it is the one gesture that makes putting it back
|
|
3406
|
+
// harder. So the maximize button docks — and the separate demote
|
|
3407
|
+
// button the WM used to inject beside it is gone, because two
|
|
3408
|
+
// buttons for one verb is how you get a chrome nobody reads.
|
|
3409
|
+
onMaximize: () => this.bringBackWindow(winId),
|
|
3410
|
+
maximizeIcon: 'close_fullscreen',
|
|
3411
|
+
maximizeTitle: 'Back to tile',
|
|
1229
3412
|
onClose: () => this._onManagedWindowClosed(winId, mountInfo),
|
|
1230
3413
|
});
|
|
3414
|
+
// DECORATED ONCE, below, after the record exists. It was called here as
|
|
3415
|
+
// well, so every Alt+N window carried two topbar context-menu handlers
|
|
3416
|
+
// — and, until R7 removed it, two "back to tile" buttons.
|
|
1231
3417
|
// leafId is null — `_onManagedWindowClosed` already short-circuits
|
|
1232
3418
|
// both branches when there's no source leaf, so the close path
|
|
1233
3419
|
// just disposes the content and drops the map entry.
|
|
@@ -1267,9 +3453,26 @@ export class WindowManager {
|
|
|
1267
3453
|
if (!leaf || leaf.kind !== 'leaf') return;
|
|
1268
3454
|
const tabs = leaf.tabs || [];
|
|
1269
3455
|
if (tabs.length === 0) return;
|
|
1270
|
-
const items = [
|
|
1271
|
-
|
|
1272
|
-
|
|
3456
|
+
const items = [];
|
|
3457
|
+
// R9. THE PER-TAB VERB LIVES ON THE TAB.
|
|
3458
|
+
//
|
|
3459
|
+
// Floating one tab out is what the pane's chrome button used to do, and
|
|
3460
|
+
// it was the wrong home for it: the button is on the PANE and named
|
|
3461
|
+
// "float this pane as a window", so it now floats the pane (R8) and
|
|
3462
|
+
// this is where the single-tab version went. A user who wants one of
|
|
3463
|
+
// three tables in a window right-clicks that table's tab, which is
|
|
3464
|
+
// where every other per-tab verb already is.
|
|
3465
|
+
//
|
|
3466
|
+
// Offered above the close verbs, and separated from them: it is the
|
|
3467
|
+
// only item here that does not destroy something, and a menu whose
|
|
3468
|
+
// first four entries all close things teaches the eye to skip it.
|
|
3469
|
+
const tab = tabs[idx];
|
|
3470
|
+
if (tab && !String(tab.kind || '').startsWith('panel:')
|
|
3471
|
+
&& tab.kind !== PLACEHOLDER_KIND) {
|
|
3472
|
+
items.push({ label: 'Open in a window', icon: 'web_asset', action: 'to-window' });
|
|
3473
|
+
items.push({ separator: true });
|
|
3474
|
+
}
|
|
3475
|
+
items.push({ label: 'Close tab', icon: 'close', action: 'close' });
|
|
1273
3476
|
if (tabs.length > 1) {
|
|
1274
3477
|
items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
|
|
1275
3478
|
}
|
|
@@ -1290,7 +3493,7 @@ export class WindowManager {
|
|
|
1290
3493
|
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
1291
3494
|
* routing) but uses `appendLeafTab` so the existing content
|
|
1292
3495
|
* stays in place as a tab. */
|
|
1293
|
-
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
3496
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
|
|
1294
3497
|
// Managed-window content: just open in the window — managed
|
|
1295
3498
|
// windows aren't tabbed (one window = one content).
|
|
1296
3499
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
@@ -1308,8 +3511,11 @@ export class WindowManager {
|
|
|
1308
3511
|
this.openInPrimary(kind, props);
|
|
1309
3512
|
return;
|
|
1310
3513
|
}
|
|
1311
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
1312
|
-
|
|
3514
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3515
|
+
{ transient, background });
|
|
3516
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3517
|
+
// is that the user stays exactly where they were.
|
|
3518
|
+
if (!background) tree.focus(leafId);
|
|
1313
3519
|
this.renderer.render();
|
|
1314
3520
|
this._persist();
|
|
1315
3521
|
this._notifyChange('tab-open');
|
|
@@ -1322,7 +3528,7 @@ export class WindowManager {
|
|
|
1322
3528
|
* click from outside the tile system (e.g. the bottom-panel
|
|
1323
3529
|
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
1324
3530
|
* tab in the main tile rather than swapping its content. */
|
|
1325
|
-
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
3531
|
+
openInTabInPrimary(kind, props = {}, transient = false, background = false) {
|
|
1326
3532
|
const tree = this._tree();
|
|
1327
3533
|
const leafId = tree.primaryLeafId();
|
|
1328
3534
|
// No content tile on this desktop (e.g. a panels-only layout) —
|
|
@@ -1330,8 +3536,11 @@ export class WindowManager {
|
|
|
1330
3536
|
// caller asked for "a tab in the main tile"; with no main tile to
|
|
1331
3537
|
// tab into, a floating window is the least-surprising fallback.
|
|
1332
3538
|
if (!leafId) { this._navigateWindow(kind, props); return; }
|
|
1333
|
-
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
1334
|
-
|
|
3539
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
|
|
3540
|
+
{ transient, background });
|
|
3541
|
+
// A BACKGROUND tab must not steal the tile's focus either — the point
|
|
3542
|
+
// is that the user stays exactly where they were.
|
|
3543
|
+
if (!background) tree.focus(leafId);
|
|
1335
3544
|
this.renderer.render();
|
|
1336
3545
|
this._persist();
|
|
1337
3546
|
this._notifyChange('tab-open');
|
|
@@ -1350,3 +3559,85 @@ function _tabTitle(kind, props) {
|
|
|
1350
3559
|
&& String(props.id)) return String(props.id);
|
|
1351
3560
|
return kind || '';
|
|
1352
3561
|
}
|
|
3562
|
+
|
|
3563
|
+
/** R8. A leaf's tabs as the plain specs a window record holds — deep enough
|
|
3564
|
+
* that editing one afterwards cannot reach back into the tree, which matters
|
|
3565
|
+
* because a floated tab keeps being edited (`openInWindow` renames it) while
|
|
3566
|
+
* the leaf it came from is still alive. A leaf with no tab list at all is
|
|
3567
|
+
* pre-tabs data; its mirrored `content`/`title` are the one tab it has. */
|
|
3568
|
+
function _leafTabSpecs(leaf) {
|
|
3569
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
3570
|
+
if (tabs.length) {
|
|
3571
|
+
return tabs.map((t) => ({
|
|
3572
|
+
kind: t.kind, props: { ...(t.props || {}) },
|
|
3573
|
+
title: t.title || t.kind || '',
|
|
3574
|
+
}));
|
|
3575
|
+
}
|
|
3576
|
+
return [{
|
|
3577
|
+
kind: leaf.content.kind,
|
|
3578
|
+
props: { ...(leaf.content.props || {}) },
|
|
3579
|
+
title: leaf.title || leaf.content.kind || '',
|
|
3580
|
+
}];
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** One half of a rectangle, in the same viewport pixels it arrived in. This is
|
|
3584
|
+
* what makes the drop preview honest: the rectangle a `left` drop on a tile
|
|
3585
|
+
* produces is half of THAT TILE, which — when the tile is itself half the
|
|
3586
|
+
* layer — is a quarter of the layer, and the preview says so. */
|
|
3587
|
+
function _halfOf(r, side) {
|
|
3588
|
+
const w = Math.round(r.width / 2);
|
|
3589
|
+
const h = Math.round(r.height / 2);
|
|
3590
|
+
switch (side) {
|
|
3591
|
+
case 'left': return { left: r.left, top: r.top, width: w, height: r.height };
|
|
3592
|
+
case 'right': return { left: r.left + r.width - w, top: r.top, width: w, height: r.height };
|
|
3593
|
+
case 'top': return { left: r.left, top: r.top, width: r.width, height: h };
|
|
3594
|
+
case 'bottom': return { left: r.left, top: r.top + r.height - h, width: r.width, height: h };
|
|
3595
|
+
default: return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/** Give `newId` half of `sourceId`'s share of their shared split, leaving every
|
|
3600
|
+
* other pane in the row untouched. This is what "the preview showed half of
|
|
3601
|
+
* that tile" means once the split is an insertion rather than a wrap. */
|
|
3602
|
+
function _halveInto(tree, sourceId, newId) {
|
|
3603
|
+
const src = tree.get(sourceId);
|
|
3604
|
+
if (!src) return false;
|
|
3605
|
+
const parent = tree.get(src.parentId);
|
|
3606
|
+
if (!parent || parent.kind !== 'split') return false;
|
|
3607
|
+
const i = parent.children.indexOf(sourceId);
|
|
3608
|
+
const j = parent.children.indexOf(newId);
|
|
3609
|
+
if (i < 0 || j < 0) return false;
|
|
3610
|
+
// The wrap case already splits a fresh two-child node evenly; only the
|
|
3611
|
+
// insertion case has a size to correct, and correcting an even one is a
|
|
3612
|
+
// no-op anyway.
|
|
3613
|
+
const share = (parent.sizes[i] ?? 1) / 2;
|
|
3614
|
+
parent.sizes[i] = share;
|
|
3615
|
+
parent.sizes[j] = share;
|
|
3616
|
+
return true;
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
/** Swap two sibling leaves inside their shared split. `TileTree.split` always
|
|
3620
|
+
* appends, and "the window goes on the left" is a legitimate outcome of a
|
|
3621
|
+
* drop; this is the one-line difference between the two. */
|
|
3622
|
+
function _swapSiblings(tree, aId, bId) {
|
|
3623
|
+
const a = tree.get(aId);
|
|
3624
|
+
const b = tree.get(bId);
|
|
3625
|
+
if (!a || !b || a.parentId !== b.parentId) return false;
|
|
3626
|
+
const parent = tree.get(a.parentId);
|
|
3627
|
+
if (!parent || parent.kind !== 'split') return false;
|
|
3628
|
+
const i = parent.children.indexOf(aId);
|
|
3629
|
+
const j = parent.children.indexOf(bId);
|
|
3630
|
+
if (i < 0 || j < 0) return false;
|
|
3631
|
+
parent.children[i] = bId;
|
|
3632
|
+
parent.children[j] = aId;
|
|
3633
|
+
// `sizes` IS PARALLEL TO `children` — `makeSplit` builds them together and
|
|
3634
|
+
// `TileTree.split` splices both at the same index. Swapping one and not the
|
|
3635
|
+
// other does not swap two panes, it makes them trade widths in place, which
|
|
3636
|
+
// looks like the drop landing on the correct side and then resizing itself
|
|
3637
|
+
// for no reason. `TileTree.moveDir` swaps both; so does this.
|
|
3638
|
+
const size = parent.sizes[i];
|
|
3639
|
+
parent.sizes[i] = parent.sizes[j];
|
|
3640
|
+
parent.sizes[j] = size;
|
|
3641
|
+
return true;
|
|
3642
|
+
}
|
|
3643
|
+
|