flexdesk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1311 -18
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2983 -142
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +135 -24
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +82 -0
- package/src/tiling/wm.js +2352 -74
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/dist/wm.js
CHANGED
|
@@ -2,11 +2,15 @@ import {
|
|
|
2
2
|
HelpModal,
|
|
3
3
|
openForm,
|
|
4
4
|
showContextMenu
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-ELXVW542.js";
|
|
6
6
|
import {
|
|
7
7
|
ManagedWindow
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-LH5TSOZW.js";
|
|
9
9
|
import "./chunk-FL5KFNQH.js";
|
|
10
|
+
import {
|
|
11
|
+
NotebookTabBar
|
|
12
|
+
} from "./chunk-QNQHQ24V.js";
|
|
13
|
+
import "./chunk-WVFGV5FT.js";
|
|
10
14
|
import "./chunk-JYWURG5T.js";
|
|
11
15
|
|
|
12
16
|
// src/tiling/command_palette.js
|
|
@@ -21,7 +25,14 @@ var STATIC_COMMANDS = [
|
|
|
21
25
|
action: () => HelpModal.open("keyboard-shortcuts")
|
|
22
26
|
}
|
|
23
27
|
];
|
|
24
|
-
function createCommandPalette({
|
|
28
|
+
function createCommandPalette({
|
|
29
|
+
wm,
|
|
30
|
+
api,
|
|
31
|
+
taxonomy,
|
|
32
|
+
catalog,
|
|
33
|
+
placeholder = "Search\u2026",
|
|
34
|
+
onPick = null
|
|
35
|
+
}) {
|
|
25
36
|
if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
|
|
26
37
|
if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
|
|
27
38
|
let overlay = null;
|
|
@@ -40,7 +51,7 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
|
|
|
40
51
|
overlay = document.createElement("div");
|
|
41
52
|
overlay.id = ROOT_ID;
|
|
42
53
|
overlay.className = "twm-cmdpal-overlay";
|
|
43
|
-
overlay.innerHTML = _markup(taxonomy, placeholder);
|
|
54
|
+
overlay.innerHTML = _markup(taxonomy, placeholder, wm);
|
|
44
55
|
document.body.appendChild(overlay);
|
|
45
56
|
overlay.addEventListener("click", (e) => {
|
|
46
57
|
if (e.target === overlay) close();
|
|
@@ -120,6 +131,15 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
|
|
|
120
131
|
}
|
|
121
132
|
return;
|
|
122
133
|
}
|
|
134
|
+
if (onPick) {
|
|
135
|
+
let handled = false;
|
|
136
|
+
try {
|
|
137
|
+
handled = onPick(pick) ?? false;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error("[cmdpal] onPick threw", err);
|
|
140
|
+
}
|
|
141
|
+
if (handled) return;
|
|
142
|
+
}
|
|
123
143
|
wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
|
|
124
144
|
};
|
|
125
145
|
const tryConsumePrefix = () => {
|
|
@@ -186,7 +206,17 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
|
|
|
186
206
|
};
|
|
187
207
|
return { open, close, toggle, isOpen };
|
|
188
208
|
}
|
|
189
|
-
function _markup(taxonomy, placeholder) {
|
|
209
|
+
function _markup(taxonomy, placeholder, wm) {
|
|
210
|
+
const PANEL_CHIPS = [
|
|
211
|
+
{ side: "left", icon: "menu", label: "Left nav" },
|
|
212
|
+
{ side: "right", icon: "dock_to_left", label: "Right panel" },
|
|
213
|
+
{ side: "bottom", icon: "dock_to_bottom", label: "Bottom panel" }
|
|
214
|
+
];
|
|
215
|
+
const panelToggles = PANEL_CHIPS.filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false).map((p) => `
|
|
216
|
+
<button class="twm-chip" data-toggle="${p.side}">
|
|
217
|
+
<span class="material-symbols-outlined">${p.icon}</span>
|
|
218
|
+
${p.label}
|
|
219
|
+
</button>`).join("");
|
|
190
220
|
const chips = taxonomy.topNavEntries().map((k) => `
|
|
191
221
|
<button class="twm-chip" data-shortcut="${k.kind}">
|
|
192
222
|
<span class="material-symbols-outlined">${k.icon}</span>
|
|
@@ -202,20 +232,7 @@ function _markup(taxonomy, placeholder) {
|
|
|
202
232
|
autocomplete="off" />
|
|
203
233
|
</div>
|
|
204
234
|
<div class="twm-cmdpal__chips">${chips}</div>
|
|
205
|
-
<div class="twm-cmdpal__toggles">
|
|
206
|
-
<button class="twm-chip" data-toggle="left">
|
|
207
|
-
<span class="material-symbols-outlined">menu</span>
|
|
208
|
-
Left nav
|
|
209
|
-
</button>
|
|
210
|
-
<button class="twm-chip" data-toggle="right">
|
|
211
|
-
<span class="material-symbols-outlined">dock_to_left</span>
|
|
212
|
-
Right panel
|
|
213
|
-
</button>
|
|
214
|
-
<button class="twm-chip" data-toggle="bottom">
|
|
215
|
-
<span class="material-symbols-outlined">dock_to_bottom</span>
|
|
216
|
-
Bottom panel
|
|
217
|
-
</button>
|
|
218
|
-
</div>
|
|
235
|
+
<div class="twm-cmdpal__toggles">${panelToggles}</div>
|
|
219
236
|
<div class="twm-cmdpal__list" data-role="list"></div>
|
|
220
237
|
<div class="twm-cmdpal__footer">
|
|
221
238
|
<span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span>
|
|
@@ -821,6 +838,70 @@ var TileTree = class _TileTree {
|
|
|
821
838
|
_syncActiveTab(n);
|
|
822
839
|
return true;
|
|
823
840
|
}
|
|
841
|
+
/**
|
|
842
|
+
* C33. MOVE ONE TAB FROM ONE LEAF TO ANOTHER, AS ONE MUTATION.
|
|
843
|
+
*
|
|
844
|
+
* Every other tab operation on this class takes ONE `leafId`, and that was
|
|
845
|
+
* a complete description of the model until a tab could be dragged into a
|
|
846
|
+
* different tile: `appendLeafTab`, `removeLeafTab`, `moveLeafTab` and the
|
|
847
|
+
* three bulk closes all begin and end inside a single leaf. The renderer's
|
|
848
|
+
* refusal to wire `onDropFromOtherPane` named this absence as one of its
|
|
849
|
+
* two reasons (`tile_renderer.js`, `_topTabCallbacks`); this is that half.
|
|
850
|
+
*
|
|
851
|
+
* ONE FUNCTION RATHER THAN A COMPOSE, and that is why it lives here rather
|
|
852
|
+
* than in the WM. `removeLeafTab` then `appendLeafTab` is two mutations
|
|
853
|
+
* with a moment between them in which the tab exists nowhere — and the
|
|
854
|
+
* second can FAIL (a panel destination refuses tabs), which leaves the tree
|
|
855
|
+
* short one tab and nothing on screen saying where it went. Every guard is
|
|
856
|
+
* therefore taken before the first splice.
|
|
857
|
+
*
|
|
858
|
+
* TWO NODES ARE RE-SYNCED, WHICH IS WHAT MAKES THIS DIFFERENT. `tabs` is
|
|
859
|
+
* the source of truth and `content`/`title` mirror `tabs[active]`
|
|
860
|
+
* (`_syncActiveTab`). Every existing mutation touches one leaf, so one
|
|
861
|
+
* re-sync is right; this one touches two, and skipping the SOURCE's leaves
|
|
862
|
+
* a pane whose chrome still names — and whose body still mounts — a tab it
|
|
863
|
+
* no longer holds. That is the mistake this operation invites, and it is
|
|
864
|
+
* what `web/js/shell/tab_drop.test.mjs` asserts against in the consumer.
|
|
865
|
+
*
|
|
866
|
+
* A same-leaf call is a REORDER and delegates, so there is one
|
|
867
|
+
* implementation of "a tab changed position within its strip" rather than
|
|
868
|
+
* two that will disagree about the active-index clamp.
|
|
869
|
+
*
|
|
870
|
+
* @param {string} fromLeafId
|
|
871
|
+
* @param {number} fromIdx
|
|
872
|
+
* @param {string} toLeafId
|
|
873
|
+
* @param {number} [toIdx=-1] where to insert; -1 (or past the end) appends
|
|
874
|
+
* @returns {{ok: boolean, toIdx: number, emptied: boolean}|null} null when
|
|
875
|
+
* the move was refused. `emptied` tells the caller the source pane now
|
|
876
|
+
* holds nothing, which is its cue to re-seed rather than leave a blank
|
|
877
|
+
* tile — the never-empty-tile invariant is the WM's to keep, not this
|
|
878
|
+
* class's.
|
|
879
|
+
*/
|
|
880
|
+
moveTabToLeaf(fromLeafId, fromIdx, toLeafId, toIdx = -1) {
|
|
881
|
+
const from = this.get(fromLeafId);
|
|
882
|
+
const to = this.get(toLeafId);
|
|
883
|
+
if (!from || from.kind !== "leaf") return null;
|
|
884
|
+
if (!to || to.kind !== "leaf") return null;
|
|
885
|
+
const tabs = Array.isArray(from.tabs) ? from.tabs : [];
|
|
886
|
+
if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
|
|
887
|
+
if (_isPanel(to)) return null;
|
|
888
|
+
if (fromLeafId === toLeafId) {
|
|
889
|
+
const dest = !Number.isInteger(toIdx) || toIdx < 0 || toIdx >= tabs.length ? tabs.length - 1 : toIdx;
|
|
890
|
+
if (!this.moveLeafTab(fromLeafId, fromIdx, dest)) return null;
|
|
891
|
+
return { ok: true, toIdx: dest, emptied: false };
|
|
892
|
+
}
|
|
893
|
+
const [moved] = from.tabs.splice(fromIdx, 1);
|
|
894
|
+
if (from.tabs.length === 0) from.activeTabIdx = 0;
|
|
895
|
+
else if (fromIdx < from.activeTabIdx) from.activeTabIdx -= 1;
|
|
896
|
+
else if (fromIdx === from.activeTabIdx) from.activeTabIdx = Math.max(0, fromIdx - 1);
|
|
897
|
+
to.tabs = Array.isArray(to.tabs) ? to.tabs : [];
|
|
898
|
+
const at = !Number.isInteger(toIdx) || toIdx < 0 || toIdx > to.tabs.length ? to.tabs.length : toIdx;
|
|
899
|
+
to.tabs.splice(at, 0, moved);
|
|
900
|
+
to.activeTabIdx = at;
|
|
901
|
+
_syncActiveTab(from);
|
|
902
|
+
_syncActiveTab(to);
|
|
903
|
+
return { ok: true, toIdx: at, emptied: from.tabs.length === 0 };
|
|
904
|
+
}
|
|
824
905
|
/**
|
|
825
906
|
* Split a leaf in the given direction; existing content stays in the
|
|
826
907
|
* original leaf, a new empty leaf is added next to it. Returns the
|
|
@@ -1095,7 +1176,7 @@ var DEFAULT_PANEL_STATE = {
|
|
|
1095
1176
|
right: true,
|
|
1096
1177
|
bottom: true
|
|
1097
1178
|
};
|
|
1098
|
-
function _makeDesktop(label, seed) {
|
|
1179
|
+
function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
|
|
1099
1180
|
const tree = new TileTree();
|
|
1100
1181
|
tree.setRoot(makeLeaf(seed()));
|
|
1101
1182
|
return {
|
|
@@ -1103,19 +1184,27 @@ function _makeDesktop(label, seed) {
|
|
|
1103
1184
|
label,
|
|
1104
1185
|
tree,
|
|
1105
1186
|
windows: [],
|
|
1106
|
-
//
|
|
1107
|
-
// assigned by wm._canonicalize via
|
|
1108
|
-
|
|
1187
|
+
// Boot default: whatever the embedder asked for, left + right + bottom
|
|
1188
|
+
// when it asked for nothing. Names are assigned by wm._canonicalize via
|
|
1189
|
+
// PANEL_TITLES.
|
|
1190
|
+
panels: { ...panelDefaults }
|
|
1109
1191
|
};
|
|
1110
1192
|
}
|
|
1111
1193
|
var DesktopManager = class _DesktopManager {
|
|
1112
|
-
/**
|
|
1113
|
-
|
|
1194
|
+
/**
|
|
1195
|
+
* @param {object} opts
|
|
1196
|
+
* @param {function} opts.seed builds the root leaf. Required.
|
|
1197
|
+
* @param {object} [opts.panelDefaults] C14. Which panel tiles a fresh
|
|
1198
|
+
* desktop opens with, merged over `DEFAULT_PANEL_STATE`. Omitted, every
|
|
1199
|
+
* desktop opens with all three — today's behaviour, unchanged.
|
|
1200
|
+
*/
|
|
1201
|
+
constructor({ seed, panelDefaults = null } = {}) {
|
|
1114
1202
|
if (typeof seed !== "function") {
|
|
1115
1203
|
throw new Error("DesktopManager: a `seed` function is required (the taxonomy root leaf)");
|
|
1116
1204
|
}
|
|
1117
1205
|
this.seed = seed;
|
|
1118
|
-
this.
|
|
1206
|
+
this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...panelDefaults || {} };
|
|
1207
|
+
this.desktops = [_makeDesktop("1", seed, this.panelDefaults)];
|
|
1119
1208
|
this.activeIdx = 0;
|
|
1120
1209
|
}
|
|
1121
1210
|
active() {
|
|
@@ -1129,11 +1218,19 @@ var DesktopManager = class _DesktopManager {
|
|
|
1129
1218
|
}
|
|
1130
1219
|
ensureCount(n) {
|
|
1131
1220
|
while (this.desktops.length < n) {
|
|
1132
|
-
this.desktops.push(_makeDesktop(
|
|
1221
|
+
this.desktops.push(_makeDesktop(
|
|
1222
|
+
String(this.desktops.length + 1),
|
|
1223
|
+
this.seed,
|
|
1224
|
+
this.panelDefaults
|
|
1225
|
+
));
|
|
1133
1226
|
}
|
|
1134
1227
|
}
|
|
1135
1228
|
addDesktop(label = null) {
|
|
1136
|
-
const d = _makeDesktop(
|
|
1229
|
+
const d = _makeDesktop(
|
|
1230
|
+
label || String(this.desktops.length + 1),
|
|
1231
|
+
this.seed,
|
|
1232
|
+
this.panelDefaults
|
|
1233
|
+
);
|
|
1137
1234
|
this.desktops.push(d);
|
|
1138
1235
|
return d;
|
|
1139
1236
|
}
|
|
@@ -1150,15 +1247,19 @@ var DesktopManager = class _DesktopManager {
|
|
|
1150
1247
|
}))
|
|
1151
1248
|
};
|
|
1152
1249
|
}
|
|
1153
|
-
static deserialize(blob, { seed } = {}) {
|
|
1154
|
-
const m = new _DesktopManager({ seed });
|
|
1250
|
+
static deserialize(blob, { seed, panelDefaults = null } = {}) {
|
|
1251
|
+
const m = new _DesktopManager({ seed, panelDefaults });
|
|
1155
1252
|
if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
|
|
1156
1253
|
m.desktops = blob.desktops.map((raw) => ({
|
|
1157
1254
|
id: raw.id || `desk-${Math.random().toString(36).slice(2, 8)}`,
|
|
1158
1255
|
label: raw.label || "?",
|
|
1159
1256
|
tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
|
|
1160
1257
|
windows: [],
|
|
1161
|
-
|
|
1258
|
+
// A RESTORED desktop's own answer wins over the default: the user
|
|
1259
|
+
// closed that panel, and re-opening it on every reload is the bug
|
|
1260
|
+
// this merge order avoids. The default only fills a key the stored
|
|
1261
|
+
// blob predates.
|
|
1262
|
+
panels: { ...m.panelDefaults, ...raw.panels || {} }
|
|
1162
1263
|
}));
|
|
1163
1264
|
for (const d of m.desktops) {
|
|
1164
1265
|
if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
|
|
@@ -1298,8 +1399,8 @@ function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
|
|
|
1298
1399
|
}
|
|
1299
1400
|
|
|
1300
1401
|
// src/tiling/keymap.js
|
|
1301
|
-
function installKeymap({ wm, palette }) {
|
|
1302
|
-
|
|
1402
|
+
function installKeymap({ wm, palette, ...opts } = {}) {
|
|
1403
|
+
const onKeyDown = (e) => {
|
|
1303
1404
|
const inField = e.target?.closest?.(
|
|
1304
1405
|
'input, textarea, select, [contenteditable="true"]'
|
|
1305
1406
|
);
|
|
@@ -1327,7 +1428,7 @@ function installKeymap({ wm, palette }) {
|
|
|
1327
1428
|
const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
|
|
1328
1429
|
if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
1329
1430
|
const btns = document.querySelectorAll(
|
|
1330
|
-
".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
|
|
1431
|
+
opts.navSelector || ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
|
|
1331
1432
|
);
|
|
1332
1433
|
const idx = Number(fMatch[1]) - 1;
|
|
1333
1434
|
if (idx < btns.length) {
|
|
@@ -1408,7 +1509,9 @@ function installKeymap({ wm, palette }) {
|
|
|
1408
1509
|
if (key.length === 1 && /[a-z]/.test(key) && !inField) {
|
|
1409
1510
|
e.preventDefault();
|
|
1410
1511
|
}
|
|
1411
|
-
}
|
|
1512
|
+
};
|
|
1513
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1514
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
1412
1515
|
}
|
|
1413
1516
|
|
|
1414
1517
|
// src/tiling/kind_taxonomy.js
|
|
@@ -2239,8 +2342,20 @@ function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
2239
2342
|
}
|
|
2240
2343
|
};
|
|
2241
2344
|
let rootLabel = rootCrumb?.label || "";
|
|
2345
|
+
const trailOf = () => {
|
|
2346
|
+
if (typeof ctx?.trailSegments !== "function") return [];
|
|
2347
|
+
try {
|
|
2348
|
+
return ctx.trailSegments() || [];
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
console.warn("[breadcrumb] trailSegments threw", err);
|
|
2351
|
+
return [];
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2242
2354
|
const render = () => {
|
|
2243
|
-
_renderInto(
|
|
2355
|
+
_renderInto(
|
|
2356
|
+
root,
|
|
2357
|
+
_segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf())
|
|
2358
|
+
);
|
|
2244
2359
|
};
|
|
2245
2360
|
render();
|
|
2246
2361
|
let unsubscribe = null;
|
|
@@ -2256,6 +2371,10 @@ function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
2256
2371
|
}
|
|
2257
2372
|
return {
|
|
2258
2373
|
el: root,
|
|
2374
|
+
/** Repaint. A trail-driven breadcrumb changes without the tile
|
|
2375
|
+
* remounting — following a lookup replaces the active tab's content in
|
|
2376
|
+
* place — so the embedder that grew the trail says when. */
|
|
2377
|
+
refresh: render,
|
|
2259
2378
|
destroy: () => {
|
|
2260
2379
|
try {
|
|
2261
2380
|
unsubscribe?.();
|
|
@@ -2265,7 +2384,7 @@ function mountTileBreadcrumb(kind, props, ctx) {
|
|
|
2265
2384
|
}
|
|
2266
2385
|
};
|
|
2267
2386
|
}
|
|
2268
|
-
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
2387
|
+
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trail) {
|
|
2269
2388
|
const segs = [];
|
|
2270
2389
|
const meta = taxonomy.meta(kind);
|
|
2271
2390
|
if (meta?.appGlobal) {
|
|
@@ -2287,6 +2406,14 @@ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
|
2287
2406
|
onClick: () => navigate(topNav)
|
|
2288
2407
|
});
|
|
2289
2408
|
}
|
|
2409
|
+
for (const step of trail || []) {
|
|
2410
|
+
if (!step?.kind) continue;
|
|
2411
|
+
segs.push({
|
|
2412
|
+
icon: taxonomy.meta(step.kind)?.icon || "description",
|
|
2413
|
+
label: step.title || step.props?.label || step.props?.id || step.kind,
|
|
2414
|
+
onClick: () => navigate(step.kind, step.props || {})
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2290
2417
|
for (const anc of taxonomy.ancestors(kind, props)) {
|
|
2291
2418
|
segs.push({
|
|
2292
2419
|
icon: taxonomy.meta(anc.kind)?.icon || "description",
|
|
@@ -2551,8 +2678,47 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
|
|
|
2551
2678
|
|
|
2552
2679
|
// src/tiling/tile_renderer.js
|
|
2553
2680
|
var SPLITTER_PX = 4;
|
|
2681
|
+
var TAB_LAYOUTS = ["bottom", "top"];
|
|
2682
|
+
var DEFAULT_TAB_LAYOUT = "bottom";
|
|
2683
|
+
function _normalizeTabLayout(value) {
|
|
2684
|
+
return TAB_LAYOUTS.includes(value) ? value : DEFAULT_TAB_LAYOUT;
|
|
2685
|
+
}
|
|
2686
|
+
function _tabKey(idx) {
|
|
2687
|
+
return `builtin://tab/${idx}`;
|
|
2688
|
+
}
|
|
2689
|
+
function _tabKeyIndex(key) {
|
|
2690
|
+
const n = Number(String(key ?? "").replace("builtin://tab/", ""));
|
|
2691
|
+
return Number.isInteger(n) && n >= 0 ? n : -1;
|
|
2692
|
+
}
|
|
2693
|
+
var TILE_LIFT_PX = 24;
|
|
2694
|
+
var CHROME_NO_FLOAT = "button, .twm-leaf__tab";
|
|
2695
|
+
function _isDownwardPull(down, sideways) {
|
|
2696
|
+
return down > 0 && sideways <= down;
|
|
2697
|
+
}
|
|
2698
|
+
var TILE_TAB_MIME = "application/x-twm-tile-tab";
|
|
2699
|
+
function _isTileTabDrag(e) {
|
|
2700
|
+
try {
|
|
2701
|
+
return !!e.dataTransfer?.types?.includes(TILE_TAB_MIME);
|
|
2702
|
+
} catch {
|
|
2703
|
+
return false;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2554
2706
|
var TileRenderer = class {
|
|
2555
|
-
|
|
2707
|
+
/** C33. Readable from a consumer that holds only the renderer — the Tables
|
|
2708
|
+
* drop suite drives real drag events and has to build a `dataTransfer`
|
|
2709
|
+
* stub that this renderer will admit. */
|
|
2710
|
+
static get TAB_MIME() {
|
|
2711
|
+
return TILE_TAB_MIME;
|
|
2712
|
+
}
|
|
2713
|
+
constructor({
|
|
2714
|
+
root,
|
|
2715
|
+
tree,
|
|
2716
|
+
content,
|
|
2717
|
+
ctx,
|
|
2718
|
+
onFocusChange,
|
|
2719
|
+
onAfterRender,
|
|
2720
|
+
tabLayout = null
|
|
2721
|
+
}) {
|
|
2556
2722
|
if (!content || typeof content.mount !== "function") {
|
|
2557
2723
|
throw new Error("TileRenderer: a content registry is required");
|
|
2558
2724
|
}
|
|
@@ -2562,12 +2728,33 @@ var TileRenderer = class {
|
|
|
2562
2728
|
this.ctx = ctx || {};
|
|
2563
2729
|
this.onFocusChange = onFocusChange || (() => {
|
|
2564
2730
|
});
|
|
2731
|
+
this.onAfterRender = onAfterRender || (() => {
|
|
2732
|
+
});
|
|
2565
2733
|
this._leafCache = /* @__PURE__ */ new Map();
|
|
2566
2734
|
this._drag = null;
|
|
2735
|
+
this._tabDrag = null;
|
|
2736
|
+
this._chromePull = null;
|
|
2737
|
+
this._tabDropProbe = null;
|
|
2738
|
+
this._tabDropPreviewEl = null;
|
|
2739
|
+
this.disposed = false;
|
|
2567
2740
|
this.root.classList.add("twm-root");
|
|
2741
|
+
this.tabLayout = _normalizeTabLayout(
|
|
2742
|
+
this.root.dataset?.twmTabs ?? tabLayout ?? DEFAULT_TAB_LAYOUT
|
|
2743
|
+
);
|
|
2744
|
+
if (this.root.dataset) this.root.dataset.twmTabs = this.tabLayout;
|
|
2745
|
+
this._layoutObserver = typeof MutationObserver === "function" ? new MutationObserver(() => this.setTabLayout(this.root.dataset?.twmTabs)) : null;
|
|
2746
|
+
this._layoutObserver?.observe(
|
|
2747
|
+
this.root,
|
|
2748
|
+
{ attributes: true, attributeFilter: ["data-twm-tabs"] }
|
|
2749
|
+
);
|
|
2568
2750
|
this.root.addEventListener("mousedown", this._onMouseDown.bind(this));
|
|
2751
|
+
this.root.addEventListener("dragover", this._onTabDragOver.bind(this));
|
|
2752
|
+
this.root.addEventListener("drop", this._onTabDrop.bind(this));
|
|
2753
|
+
this.root.addEventListener("dragleave", this._onTabDragLeave.bind(this));
|
|
2754
|
+
this.root.addEventListener("dragend", this._onTabDragEnd.bind(this));
|
|
2569
2755
|
}
|
|
2570
2756
|
render() {
|
|
2757
|
+
if (this.disposed) return;
|
|
2571
2758
|
const savedScrolls = /* @__PURE__ */ new Map();
|
|
2572
2759
|
for (const [leafId, entry] of this._leafCache.entries()) {
|
|
2573
2760
|
const snap = [];
|
|
@@ -2579,6 +2766,8 @@ var TileRenderer = class {
|
|
|
2579
2766
|
savedScrolls.set(leafId, snap);
|
|
2580
2767
|
entry.wrapEl.remove();
|
|
2581
2768
|
}
|
|
2769
|
+
const floats = [...this.root.children].filter((el) => el.classList?.contains("twm-managed-window") || el.classList?.contains("twm-managed-window__backdrop"));
|
|
2770
|
+
for (const el of floats) el.remove();
|
|
2582
2771
|
this.root.innerHTML = "";
|
|
2583
2772
|
const tree = this.tree;
|
|
2584
2773
|
if (!tree.rootId) {
|
|
@@ -2587,11 +2776,13 @@ var TileRenderer = class {
|
|
|
2587
2776
|
empty.textContent = "Empty desktop \u2014 Ctrl+K to open something.";
|
|
2588
2777
|
this.root.appendChild(empty);
|
|
2589
2778
|
this._cleanCache(/* @__PURE__ */ new Set());
|
|
2779
|
+
for (const el of floats) this.root.appendChild(el);
|
|
2590
2780
|
return;
|
|
2591
2781
|
}
|
|
2592
2782
|
const liveLeafIds = /* @__PURE__ */ new Set();
|
|
2593
2783
|
this._mount(tree.rootId, this.root, liveLeafIds);
|
|
2594
2784
|
this._cleanCache(liveLeafIds);
|
|
2785
|
+
for (const el of floats) this.root.appendChild(el);
|
|
2595
2786
|
this._updateFocusClasses();
|
|
2596
2787
|
const restore = () => {
|
|
2597
2788
|
for (const [leafId, snap] of savedScrolls) {
|
|
@@ -2606,6 +2797,24 @@ var TileRenderer = class {
|
|
|
2606
2797
|
};
|
|
2607
2798
|
restore();
|
|
2608
2799
|
requestAnimationFrame(restore);
|
|
2800
|
+
try {
|
|
2801
|
+
this.onAfterRender();
|
|
2802
|
+
} catch (err) {
|
|
2803
|
+
console.error("[tile-renderer] onAfterRender threw", err);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
/** Unmount everything and stop painting. `dispose()` on the shell calls
|
|
2807
|
+
* this: `root.innerHTML = ''` detaches DOM without telling a single content
|
|
2808
|
+
* factory, so a page module that installed a `window` listener or an
|
|
2809
|
+
* interval keeps both, invisibly, for the life of the tab. */
|
|
2810
|
+
destroy() {
|
|
2811
|
+
this._layoutObserver?.disconnect();
|
|
2812
|
+
this._layoutObserver = null;
|
|
2813
|
+
this._clearTabDropPreview();
|
|
2814
|
+
this._tabDrag = null;
|
|
2815
|
+
this._tabDropProbe = null;
|
|
2816
|
+
this._cleanCache(/* @__PURE__ */ new Set());
|
|
2817
|
+
this.disposed = true;
|
|
2609
2818
|
}
|
|
2610
2819
|
_cleanCache(liveSet) {
|
|
2611
2820
|
for (const [leafId, entry] of [...this._leafCache.entries()]) {
|
|
@@ -2614,6 +2823,7 @@ var TileRenderer = class {
|
|
|
2614
2823
|
entry.content?.destroy?.();
|
|
2615
2824
|
} catch (_) {
|
|
2616
2825
|
}
|
|
2826
|
+
this._disposeTabStrip(entry);
|
|
2617
2827
|
entry.wrapEl.remove();
|
|
2618
2828
|
this._leafCache.delete(leafId);
|
|
2619
2829
|
}
|
|
@@ -2651,18 +2861,20 @@ var TileRenderer = class {
|
|
|
2651
2861
|
}
|
|
2652
2862
|
_leafEl(leaf) {
|
|
2653
2863
|
const activeTab = Array.isArray(leaf.tabs) && leaf.tabs.length > 0 ? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))] : null;
|
|
2654
|
-
const
|
|
2655
|
-
const kindKey = leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
|
|
2864
|
+
const kindKey = this._leafKindKey(leaf);
|
|
2656
2865
|
let entry = this._leafCache.get(leaf.id);
|
|
2657
2866
|
if (entry && entry.kindKey === kindKey) {
|
|
2658
2867
|
entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
|
|
2868
|
+
_paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
|
|
2659
2869
|
return entry.wrapEl;
|
|
2660
2870
|
}
|
|
2661
2871
|
if (entry) {
|
|
2872
|
+
this._leafCache.delete(leaf.id);
|
|
2662
2873
|
try {
|
|
2663
2874
|
entry.content?.destroy?.();
|
|
2664
2875
|
} catch (_) {
|
|
2665
2876
|
}
|
|
2877
|
+
this._disposeTabStrip(entry);
|
|
2666
2878
|
entry.wrapEl.remove();
|
|
2667
2879
|
}
|
|
2668
2880
|
const wrap = document.createElement("div");
|
|
@@ -2670,11 +2882,15 @@ var TileRenderer = class {
|
|
|
2670
2882
|
wrap.dataset.leafId = leaf.id;
|
|
2671
2883
|
const chrome = document.createElement("div");
|
|
2672
2884
|
chrome.className = "twm-leaf__chrome";
|
|
2885
|
+
const icon = document.createElement("span");
|
|
2886
|
+
icon.className = "twm-leaf__icon material-symbols-outlined";
|
|
2673
2887
|
const title = document.createElement("span");
|
|
2674
2888
|
title.className = "twm-leaf__title";
|
|
2675
2889
|
title.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
|
|
2676
2890
|
const actions = document.createElement("span");
|
|
2677
2891
|
actions.className = "twm-leaf__actions";
|
|
2892
|
+
const contentActions = document.createElement("span");
|
|
2893
|
+
contentActions.className = "twm-leaf__actions twm-leaf__actions--content";
|
|
2678
2894
|
const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
|
|
2679
2895
|
actions.innerHTML = `
|
|
2680
2896
|
${isPanel ? "" : `
|
|
@@ -2684,14 +2900,15 @@ var TileRenderer = class {
|
|
|
2684
2900
|
<button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
|
|
2685
2901
|
<span class="material-symbols-outlined">splitscreen_add</span>
|
|
2686
2902
|
</button>
|
|
2687
|
-
<button class="twm-leaf__btn" data-action="promote" title="
|
|
2688
|
-
<span class="material-symbols-outlined">
|
|
2903
|
+
<button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
|
|
2904
|
+
<span class="material-symbols-outlined">web_asset</span>
|
|
2689
2905
|
</button>`}
|
|
2690
2906
|
<button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
|
|
2691
2907
|
<span class="material-symbols-outlined">close</span>
|
|
2692
2908
|
</button>
|
|
2693
2909
|
`;
|
|
2694
|
-
chrome.append(title, actions);
|
|
2910
|
+
chrome.append(icon, title, contentActions, actions);
|
|
2911
|
+
_paintLeafIcon(icon, leaf, null, this.ctx);
|
|
2695
2912
|
const body = document.createElement("div");
|
|
2696
2913
|
body.className = "twm-leaf__body";
|
|
2697
2914
|
body.tabIndex = -1;
|
|
@@ -2700,7 +2917,7 @@ var TileRenderer = class {
|
|
|
2700
2917
|
if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
|
|
2701
2918
|
tabBar.classList.add("twm-leaf__tabbar--hidden");
|
|
2702
2919
|
}
|
|
2703
|
-
wrap.append(chrome, body, tabBar);
|
|
2920
|
+
wrap.append(chrome, ...this.tabLayout === "top" ? [tabBar, body] : [body, tabBar]);
|
|
2704
2921
|
wrap.addEventListener("mousedown", (e) => {
|
|
2705
2922
|
if (e.target.closest(".twm-splitter")) return;
|
|
2706
2923
|
this.tree.focus(leaf.id);
|
|
@@ -2718,6 +2935,38 @@ var TileRenderer = class {
|
|
|
2718
2935
|
}, 0);
|
|
2719
2936
|
}
|
|
2720
2937
|
});
|
|
2938
|
+
chrome.addEventListener("pointerdown", (e) => {
|
|
2939
|
+
if (e.button !== 0) return;
|
|
2940
|
+
if (e.target.closest(CHROME_NO_FLOAT)) return;
|
|
2941
|
+
const startY = e.clientY;
|
|
2942
|
+
const startX = e.clientX;
|
|
2943
|
+
const pull = { startX, startY, x: startX, y: startY, lifted: false };
|
|
2944
|
+
this._chromePull = pull;
|
|
2945
|
+
const onMove = (move) => {
|
|
2946
|
+
pull.x = move.clientX;
|
|
2947
|
+
pull.y = move.clientY;
|
|
2948
|
+
if (pull.lifted) return;
|
|
2949
|
+
const down = move.clientY - startY;
|
|
2950
|
+
const sideways = Math.abs(move.clientX - startX);
|
|
2951
|
+
if (down < TILE_LIFT_PX || !_isDownwardPull(down, sideways)) return;
|
|
2952
|
+
pull.lifted = true;
|
|
2953
|
+
window.removeEventListener("pointermove", onMove);
|
|
2954
|
+
this.ctx.onLeafAction?.(leaf.id, "promote");
|
|
2955
|
+
};
|
|
2956
|
+
const cleanup = () => {
|
|
2957
|
+
if (this._chromePull === pull) this._chromePull = null;
|
|
2958
|
+
window.removeEventListener("pointermove", onMove);
|
|
2959
|
+
window.removeEventListener("pointerup", cleanup);
|
|
2960
|
+
window.removeEventListener("pointercancel", cleanup);
|
|
2961
|
+
};
|
|
2962
|
+
window.addEventListener("pointermove", onMove);
|
|
2963
|
+
window.addEventListener("pointerup", cleanup);
|
|
2964
|
+
window.addEventListener("pointercancel", cleanup);
|
|
2965
|
+
});
|
|
2966
|
+
chrome.addEventListener("dblclick", (e) => {
|
|
2967
|
+
if (e.target.closest(CHROME_NO_FLOAT)) return;
|
|
2968
|
+
this.ctx.onLeafAction?.(leaf.id, "promote");
|
|
2969
|
+
});
|
|
2721
2970
|
chrome.addEventListener("contextmenu", (e) => {
|
|
2722
2971
|
e.preventDefault();
|
|
2723
2972
|
this.tree.focus(leaf.id);
|
|
@@ -2737,6 +2986,9 @@ var TileRenderer = class {
|
|
|
2737
2986
|
const activeProps = activeTab?.props ?? leaf.content.props;
|
|
2738
2987
|
content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
|
|
2739
2988
|
if (content.title) title.textContent = content.title;
|
|
2989
|
+
_paintLeafIcon(icon, leaf, content, this.ctx);
|
|
2990
|
+
_paintContentActions(contentActions, content.chromeActions);
|
|
2991
|
+
_vetoStructuralActions(actions, content.chrome);
|
|
2740
2992
|
} else {
|
|
2741
2993
|
body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
|
|
2742
2994
|
}
|
|
@@ -2745,33 +2997,418 @@ var TileRenderer = class {
|
|
|
2745
2997
|
bodyEl: body,
|
|
2746
2998
|
chromeEl: chrome,
|
|
2747
2999
|
titleEl: title,
|
|
3000
|
+
iconEl: icon,
|
|
2748
3001
|
content,
|
|
2749
|
-
kindKey
|
|
2750
|
-
|
|
3002
|
+
// DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
|
|
3003
|
+
// before the mount; content that records its sub-tab or its
|
|
3004
|
+
// scroll offset WHILE mounting has already written to the
|
|
3005
|
+
// tree by now, so the value captured earlier is stale and the
|
|
3006
|
+
// very next repaint would tear down the tile that had just
|
|
3007
|
+
// said where it was.
|
|
3008
|
+
kindKey: this._leafKindKey(leaf),
|
|
3009
|
+
tabBarEl: tabBar,
|
|
3010
|
+
// C22. The `top` layout's NotebookTabBar, and the child it
|
|
3011
|
+
// mounts into. Null under the `bottom` layout, which is plain
|
|
3012
|
+
// markup this file writes itself.
|
|
3013
|
+
tabStrip: null,
|
|
3014
|
+
tabStripHostEl: null
|
|
2751
3015
|
};
|
|
2752
3016
|
this._leafCache.set(leaf.id, entry);
|
|
2753
3017
|
this._renderTabBar(leaf, entry);
|
|
2754
3018
|
return wrap;
|
|
2755
3019
|
}
|
|
2756
|
-
/**
|
|
2757
|
-
*
|
|
2758
|
-
*
|
|
2759
|
-
*
|
|
3020
|
+
/**
|
|
3021
|
+
* The cache key for a leaf: everything that must change before its wrap is
|
|
3022
|
+
* torn down and rebuilt. Extracted so `rebaselineLeaf` below computes the
|
|
3023
|
+
* same string this does — two spellings of one key is a cache that misses
|
|
3024
|
+
* on every render or never misses at all, and both look like working code.
|
|
3025
|
+
*/
|
|
3026
|
+
_leafKindKey(leaf) {
|
|
3027
|
+
const tabFingerprint = (leaf.tabs || []).map((t) => `${t.kind}::${JSON.stringify(t.props || {})}`).join("|") + `#${leaf.activeTabIdx || 0}`;
|
|
3028
|
+
return leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* The cache key a leaf has RIGHT NOW, for a caller that is about to change
|
|
3032
|
+
* the tree and wants to say what it expected to be changing from. See
|
|
3033
|
+
* `rebaselineLeaf`.
|
|
3034
|
+
*/
|
|
3035
|
+
leafKey(leafId) {
|
|
3036
|
+
const leaf = this.tree?.get?.(leafId);
|
|
3037
|
+
return leaf && leaf.kind === "leaf" ? this._leafKindKey(leaf) : null;
|
|
3038
|
+
}
|
|
3039
|
+
/**
|
|
3040
|
+
* C34. ACCEPT A PROPS WRITE THE CONTENT MADE ABOUT ITSELF, WITHOUT
|
|
3041
|
+
* REBUILDING THE TILE THAT MADE IT.
|
|
3042
|
+
*
|
|
3043
|
+
* The cache key above includes every tab's props, and that is right for
|
|
3044
|
+
* NAVIGATION: a `table` tab whose `id` changes is different content and the
|
|
3045
|
+
* tile must be re-mounted. It is exactly wrong for VIEW STATE. The
|
|
3046
|
+
* framework hands every tile a `workspaceTabs.updateProps(patch)`
|
|
3047
|
+
* (`page_factory.js`) documented as *"persist editor sub-state into this
|
|
3048
|
+
* tile's WM tab props"* — a scroll offset, an open section, a selected
|
|
3049
|
+
* sub-tab. Writing one changed the fingerprint, so the NEXT repaint (a
|
|
3050
|
+
* focus change, a tab switch, a window promoted three tiles away) missed
|
|
3051
|
+
* the cache, destroyed the content and mounted it again. The tile was torn
|
|
3052
|
+
* down BY the call that existed to let it remember something, and because
|
|
3053
|
+
* the rebuild reads the props back the result looked almost right — the
|
|
3054
|
+
* editor came back at the saved scroll position, with everything uncommitted
|
|
3055
|
+
* in it gone.
|
|
3056
|
+
*
|
|
3057
|
+
* So the key is re-baselined instead: the entry keeps its live DOM and
|
|
3058
|
+
* starts answering to the new props. The next real navigation still misses
|
|
3059
|
+
* and still rebuilds, because that changes the kind or the tab set and this
|
|
3060
|
+
* only ever accepts what is already on screen.
|
|
3061
|
+
*
|
|
3062
|
+
* ══ IT ACCEPTS ONLY THE DELTA IT WAS CALLED FOR ═══════════════════
|
|
3063
|
+
*
|
|
3064
|
+
* The key is re-derived from the tree as it is NOW, so a first version of
|
|
3065
|
+
* this swallowed every difference at once — including a change the tree had
|
|
3066
|
+
* taken and the renderer had not drawn yet. Any mutation that does not
|
|
3067
|
+
* repaint (`updateActiveTabProps` is itself one, and an embedder writing
|
|
3068
|
+
* through `TileTree` directly is another) followed by a props write would
|
|
3069
|
+
* have been accepted onto a wrap still showing the OLD content, and the
|
|
3070
|
+
* tile would never have re-mounted: one thing drawn under another's title,
|
|
3071
|
+
* permanently, with nothing left that knows the two disagree.
|
|
3072
|
+
*
|
|
3073
|
+
* `expected` is the fix and it is the caller's own honesty: the key it read
|
|
3074
|
+
* BEFORE its write. If the cached entry is not still at that key, something
|
|
3075
|
+
* else has changed since the tile was mounted and this is not the caller's
|
|
3076
|
+
* to accept — refuse, and let the ordinary miss rebuild it.
|
|
3077
|
+
*
|
|
3078
|
+
* A structural test was tried first and is not enough. *Same kind, different
|
|
3079
|
+
* props* is a scroll offset AND it is a navigation to another table; nothing
|
|
3080
|
+
* in the leaf can tell them apart, because the difference is which caller
|
|
3081
|
+
* asked. `expected` asks the caller.
|
|
3082
|
+
*
|
|
3083
|
+
* @param {string} leafId
|
|
3084
|
+
* @param {string} [expected] the key the caller read before its own write.
|
|
3085
|
+
* Omitted means "accept whatever is there", which is what the first
|
|
3086
|
+
* version did and is kept only so an older caller does not silently
|
|
3087
|
+
* change behaviour — every caller in this tree passes one.
|
|
3088
|
+
* @returns {boolean} whether a cached wrap was re-baselined
|
|
3089
|
+
*/
|
|
3090
|
+
rebaselineLeaf(leafId, expected = void 0) {
|
|
3091
|
+
const entry = this._leafCache.get(leafId);
|
|
3092
|
+
const leaf = this.tree?.get?.(leafId);
|
|
3093
|
+
if (!entry || !leaf || leaf.kind !== "leaf") return false;
|
|
3094
|
+
if (expected !== void 0 && entry.kindKey !== expected) return false;
|
|
3095
|
+
entry.kindKey = this._leafKindKey(leaf);
|
|
3096
|
+
return true;
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
3099
|
+
* C22. Change the tab layout of a LIVE renderer.
|
|
3100
|
+
*
|
|
3101
|
+
* Only the strip is rebuilt. The tab bar element is MOVED between its two
|
|
3102
|
+
* positions and the body is never detached, so no content factory is
|
|
3103
|
+
* unmounted — which is the whole reason this is a method rather than
|
|
3104
|
+
* "rebuild the shell with the other option". A grid holding staged edits
|
|
3105
|
+
* must not lose them because someone changed where its tabs are drawn.
|
|
3106
|
+
*
|
|
3107
|
+
* Accepts anything: the value arrives from a DOM attribute and from
|
|
3108
|
+
* persisted user settings, and an unknown one means the default.
|
|
3109
|
+
*/
|
|
3110
|
+
setTabLayout(layout) {
|
|
3111
|
+
const next = _normalizeTabLayout(layout);
|
|
3112
|
+
if (next === this.tabLayout) return;
|
|
3113
|
+
this.tabLayout = next;
|
|
3114
|
+
if (this.root.dataset && this.root.dataset.twmTabs !== next) {
|
|
3115
|
+
this.root.dataset.twmTabs = next;
|
|
3116
|
+
}
|
|
3117
|
+
if (this.disposed) return;
|
|
3118
|
+
for (const [leafId, entry] of this._leafCache) {
|
|
3119
|
+
this._placeTabBar(entry);
|
|
3120
|
+
this._disposeTabStrip(entry);
|
|
3121
|
+
entry.tabBarEl.innerHTML = "";
|
|
3122
|
+
const leaf = this.tree.get(leafId);
|
|
3123
|
+
if (leaf) this._renderTabBar(leaf, entry);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
/** Put a leaf's tab bar on the side the current layout says. Moving an
|
|
3127
|
+
* attached element is a re-parent, not a rebuild — the body keeps its
|
|
3128
|
+
* DOM, its listeners and its scroll. */
|
|
3129
|
+
_placeTabBar(entry) {
|
|
3130
|
+
const { wrapEl, bodyEl, tabBarEl } = entry;
|
|
3131
|
+
if (!wrapEl || !tabBarEl || !bodyEl) return;
|
|
3132
|
+
if (this.tabLayout === "top") wrapEl.insertBefore(tabBarEl, bodyEl);
|
|
3133
|
+
else wrapEl.appendChild(tabBarEl);
|
|
3134
|
+
}
|
|
3135
|
+
/** Tear down the `top` layout's component, if this leaf has one. Safe to
|
|
3136
|
+
* call on a leaf that never had one, and on one that already lost it. */
|
|
3137
|
+
_disposeTabStrip(entry) {
|
|
3138
|
+
if (!entry?.tabStrip) return;
|
|
3139
|
+
try {
|
|
3140
|
+
entry.tabStrip.dispose();
|
|
3141
|
+
} catch (err) {
|
|
3142
|
+
console.error("[tile] tab strip dispose threw", err);
|
|
3143
|
+
}
|
|
3144
|
+
entry.tabStrip = null;
|
|
3145
|
+
entry.tabStripHostEl = null;
|
|
3146
|
+
}
|
|
3147
|
+
/** Paint a leaf's tab strip in whichever layout is current. The strip is
|
|
3148
|
+
* hidden for single-tab leaves in BOTH layouts — the common case, and the
|
|
3149
|
+
* reason existing single-pane layouts read as identical to today. */
|
|
2760
3150
|
_renderTabBar(leaf, entry) {
|
|
3151
|
+
this._syncChromeDragSource(leaf, entry);
|
|
2761
3152
|
const bar = entry.tabBarEl;
|
|
2762
3153
|
if (!bar) return;
|
|
2763
3154
|
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
3155
|
+
bar.classList.toggle("twm-leaf__tabbar--top", this.tabLayout === "top");
|
|
2764
3156
|
if (tabs.length <= 1) {
|
|
2765
3157
|
bar.classList.add("twm-leaf__tabbar--hidden");
|
|
3158
|
+
this._disposeTabStrip(entry);
|
|
2766
3159
|
bar.innerHTML = "";
|
|
2767
3160
|
return;
|
|
2768
3161
|
}
|
|
2769
3162
|
bar.classList.remove("twm-leaf__tabbar--hidden");
|
|
3163
|
+
if (this.tabLayout === "top") this._renderTopTabBar(leaf, entry, tabs);
|
|
3164
|
+
else this._renderBottomTabBar(leaf, entry, tabs);
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* C33. THE CHROME IS THE DRAG SOURCE FOR A LEAF THAT HAS ONE TAB.
|
|
3168
|
+
*
|
|
3169
|
+
* Product owner, 2026-08-27. The full argument is in the file header; what
|
|
3170
|
+
* this function owns is the THREE conditions and why each is a condition
|
|
3171
|
+
* rather than a preference:
|
|
3172
|
+
*
|
|
3173
|
+
* EXACTLY ONE TAB. With two or more, the strip is drawn and names each
|
|
3174
|
+
* tab; a chrome drag would then have to guess which one was meant, and
|
|
3175
|
+
* guessing is what the strip exists to avoid. With exactly one, "this
|
|
3176
|
+
* tab" and "what is in this pane" are the same thing.
|
|
3177
|
+
*
|
|
3178
|
+
* NOT A PANEL. Panel tiles are chrome, not content — the same exclusion
|
|
3179
|
+
* `_floatableLeaf` and `_snapProbe` already make, and for the same reason:
|
|
3180
|
+
* there is nothing in them that belongs anywhere else.
|
|
3181
|
+
*
|
|
3182
|
+
* THE CONTENT DID NOT VETO `promote` (C20). One rule instead of two: a
|
|
3183
|
+
* tab you may not lift out of its pane is a tab you may not drag into
|
|
3184
|
+
* another one. This is what excludes an embedder's master tile — the
|
|
3185
|
+
* ground its floating windows stand on — whose whole reason for existing
|
|
3186
|
+
* is that it stays where it is.
|
|
3187
|
+
*
|
|
3188
|
+
* THE LISTENERS ARE BOUND ONCE PER CHROME ELEMENT and the ATTRIBUTE is
|
|
3189
|
+
* re-decided on every pass. That split is deliberate: `draggable` changes
|
|
3190
|
+
* the moment a second tab arrives, while the chrome element itself survives
|
|
3191
|
+
* for as long as its wrap does, and re-adding a listener on every render
|
|
3192
|
+
* would stack one per repaint.
|
|
3193
|
+
*/
|
|
3194
|
+
_syncChromeDragSource(leaf, entry) {
|
|
3195
|
+
const chrome = entry?.chromeEl;
|
|
3196
|
+
if (!chrome) return;
|
|
3197
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
3198
|
+
const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
|
|
3199
|
+
const vetoed = this.leafChrome(leaf.id)?.promote === false;
|
|
3200
|
+
const on = tabs.length === 1 && !isPanel && !vetoed;
|
|
3201
|
+
if (on) chrome.setAttribute("draggable", "true");
|
|
3202
|
+
else chrome.removeAttribute("draggable");
|
|
3203
|
+
if (chrome.__twmTabDragBound) return;
|
|
3204
|
+
chrome.__twmTabDragBound = true;
|
|
3205
|
+
chrome.addEventListener("dragstart", (ev) => {
|
|
3206
|
+
if (ev.target?.closest?.(CHROME_NO_FLOAT)) {
|
|
3207
|
+
ev.preventDefault();
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
const pull = this._chromePull;
|
|
3211
|
+
if (pull && _isDownwardPull(
|
|
3212
|
+
pull.y - pull.startY,
|
|
3213
|
+
Math.abs(pull.x - pull.startX)
|
|
3214
|
+
)) {
|
|
3215
|
+
ev.preventDefault();
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
const live = this.tree.get(leaf.id);
|
|
3219
|
+
if ((live?.tabs || []).length !== 1) {
|
|
3220
|
+
ev.preventDefault();
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
this._beginTabDrag(ev, leaf.id, 0, chrome, { seedPlainText: true });
|
|
3224
|
+
});
|
|
3225
|
+
chrome.addEventListener("dragend", () => this._onTabDragEnd());
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* C22. The `top` layout: `NotebookTabBar`, the editor tab strip.
|
|
3229
|
+
*
|
|
3230
|
+
* The component is mounted into a CHILD of the bar rather than into the bar
|
|
3231
|
+
* itself, because `mount()` assigns `container.className = 'tabs
|
|
3232
|
+
* notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it `.twm-leaf__tabbar`
|
|
3233
|
+
* would take that class, and with it the strip's height, its background and
|
|
3234
|
+
* `--hidden`, off the element this file still controls.
|
|
3235
|
+
*
|
|
3236
|
+
* Every gesture routes through the SAME `ctx.onLeafTabAction` vocabulary the
|
|
3237
|
+
* bottom strip uses, so the WM's tree mutations, its persistence and its
|
|
3238
|
+
* change notifications are reached by one path from both layouts.
|
|
3239
|
+
*/
|
|
3240
|
+
_renderTopTabBar(leaf, entry, tabs) {
|
|
3241
|
+
const bar = entry.tabBarEl;
|
|
3242
|
+
if (!entry.tabStrip) {
|
|
3243
|
+
bar.innerHTML = "";
|
|
3244
|
+
const host = document.createElement("div");
|
|
3245
|
+
bar.appendChild(host);
|
|
3246
|
+
const strip = new NotebookTabBar();
|
|
3247
|
+
strip.mount(host, this._topTabCallbacks(leaf.id));
|
|
3248
|
+
entry.tabStrip = strip;
|
|
3249
|
+
entry.tabStripHostEl = host;
|
|
3250
|
+
host.addEventListener("contextmenu", (ev) => {
|
|
3251
|
+
const tabEl = ev.target.closest?.(".tab");
|
|
3252
|
+
if (!tabEl) return;
|
|
3253
|
+
ev.preventDefault();
|
|
3254
|
+
ev.stopPropagation();
|
|
3255
|
+
const idx = this._topTabIndex(host, tabEl);
|
|
3256
|
+
if (idx < 0) return;
|
|
3257
|
+
this.ctx.onLeafTabAction?.(
|
|
3258
|
+
leaf.id,
|
|
3259
|
+
"menu",
|
|
3260
|
+
{ idx, x: ev.clientX, y: ev.clientY }
|
|
3261
|
+
);
|
|
3262
|
+
}, true);
|
|
3263
|
+
}
|
|
3264
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
3265
|
+
entry.tabStrip.update(
|
|
3266
|
+
tabs.map((t, i) => ({
|
|
3267
|
+
filePath: _tabKey(i),
|
|
3268
|
+
label: t.title || t.kind || "",
|
|
3269
|
+
// The component's `fileType` picks a glyph out of a static map
|
|
3270
|
+
// of FILE kinds. A tile's kinds are the embedder's, and the
|
|
3271
|
+
// taxonomy already answers for them — see `_paintTopTabs`.
|
|
3272
|
+
fileType: t.kind || "unknown",
|
|
3273
|
+
// Nothing sets `dirty` on a tab spec today, so the dot is never
|
|
3274
|
+
// drawn. Read anyway, because the day a tile can say it holds
|
|
3275
|
+
// unsaved work this is where it says it, and the alternative is
|
|
3276
|
+
// a second place to remember.
|
|
3277
|
+
isDirty: !!t.dirty
|
|
3278
|
+
})),
|
|
3279
|
+
_tabKey(activeIdx)
|
|
3280
|
+
);
|
|
3281
|
+
this._paintTopTabs(entry.tabStripHostEl, tabs);
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* The two things `NotebookTabBar` derives from a vocabulary a tile does not
|
|
3285
|
+
* have, corrected in one pass over the DOM it just wrote.
|
|
3286
|
+
*
|
|
3287
|
+
* Its tooltip is the file PATH (`notebook_tab_bar.js:163`) and its glyph
|
|
3288
|
+
* comes from a static fileType map (`:135`). Ours are `builtin://tab/3` and
|
|
3289
|
+
* a content kind, so left alone a tab would advertise its own array index
|
|
3290
|
+
* and wear the generic `description` glyph — while the tile chrome an inch
|
|
3291
|
+
* above it shows the taxonomy's icon for exactly the same kind.
|
|
3292
|
+
*
|
|
3293
|
+
* Reaching into a component's DOM is worth one paragraph of justification.
|
|
3294
|
+
* The alternative for the glyph is `NotebookTabBar.setFileTypeIcons()`,
|
|
3295
|
+
* which is STATIC and REPLACES the whole map — so a page that also uses the
|
|
3296
|
+
* editor would find its own file icons deleted by whichever of the two
|
|
3297
|
+
* rendered last. The class names used here are the component's published
|
|
3298
|
+
* contract, stated in its header comment.
|
|
3299
|
+
*/
|
|
3300
|
+
_paintTopTabs(hostEl, tabs) {
|
|
3301
|
+
if (!hostEl) return;
|
|
3302
|
+
const els = hostEl.querySelectorAll(".tab");
|
|
3303
|
+
els.forEach((el, i) => {
|
|
3304
|
+
const spec = tabs[i];
|
|
3305
|
+
if (!spec) return;
|
|
3306
|
+
el.title = spec.title || spec.kind || "";
|
|
3307
|
+
const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
|
|
3308
|
+
const glyph = el.querySelector(".tab-icon");
|
|
3309
|
+
if (glyph && icon) glyph.textContent = icon;
|
|
3310
|
+
else if (glyph && !icon) glyph.hidden = true;
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
/** Which tab an element in the top strip is, by DOM position. Position
|
|
3314
|
+
* rather than the `data-path` key because the key is only ever the index
|
|
3315
|
+
* and reading it back would be a second, parallel answer to the same
|
|
3316
|
+
* question. */
|
|
3317
|
+
_topTabIndex(hostEl, tabEl) {
|
|
3318
|
+
return Array.prototype.indexOf.call(hostEl.querySelectorAll(".tab"), tabEl);
|
|
3319
|
+
}
|
|
3320
|
+
/** The callbacks `NotebookTabBar` calls. Everything the component offers
|
|
3321
|
+
* that a tile tab cannot honour is deliberately absent rather than stubbed
|
|
3322
|
+
* — `onRename` is refused by the `builtin://` key, and the rest
|
|
3323
|
+
* (`onDuplicate`, `onSplitRight`, `onRevealInExplorer`, `onCloseAll`, …)
|
|
3324
|
+
* are only ever reached from the context menu this renderer suppresses.
|
|
3325
|
+
*
|
|
3326
|
+
* `onDropFromOtherPane` IS NOW WIRED, AND THIS IS THE RECORD OF WHY IT WAS
|
|
3327
|
+
* NOT. The refusal read: *"its payload is the dragged tab's key alone,
|
|
3328
|
+
* which carries no source-leaf identity, and `TileTree` has no
|
|
3329
|
+
* move-a-tab-between-leaves operation to receive it. Wiring it would need
|
|
3330
|
+
* both, and both are tree changes."* Both were true and C33 built both.
|
|
3331
|
+
* `TileTree.moveTabToLeaf` is the tree operation; `TileRenderer._tabDrag`
|
|
3332
|
+
* is the source identity — held on the renderer rather than in the payload
|
|
3333
|
+
* because HTML5's protected mode makes the payload unreadable at the only
|
|
3334
|
+
* moment it would be needed (see `TILE_TAB_MIME`). The payload handed to
|
|
3335
|
+
* the callback is therefore still ignored, exactly as the refusal said it
|
|
3336
|
+
* would have to be.
|
|
3337
|
+
*
|
|
3338
|
+
* A DROP ON A FOREIGN STRIP APPENDS. `NotebookTabBar` hands the callback a
|
|
3339
|
+
* key and no event, so there is no pointer position to derive a slot from
|
|
3340
|
+
* — and inventing one for this strip and not for the bottom one would give
|
|
3341
|
+
* the two layouts different answers to the same gesture. "Add this tab to
|
|
3342
|
+
* that tile" is what was asked for; where it sits in the strip is a
|
|
3343
|
+
* reorder away, in the mechanism that already does reorders. */
|
|
3344
|
+
_topTabCallbacks(leafId) {
|
|
3345
|
+
return {
|
|
3346
|
+
// C33. `DragReorder` calls this through `NotebookTabBar`, which
|
|
3347
|
+
// sets its own `application/x-ecosim-tab` first and unchanged — so
|
|
3348
|
+
// an editor pane sharing the page cannot notice that tiles are
|
|
3349
|
+
// dragging tabs too.
|
|
3350
|
+
onDragStart: (ev, key, item) => {
|
|
3351
|
+
const idx = _tabKeyIndex(key);
|
|
3352
|
+
if (idx < 0) return;
|
|
3353
|
+
this._beginTabDrag(ev, leafId, idx, item || null);
|
|
3354
|
+
},
|
|
3355
|
+
// What lets THIS strip admit a tab dragged out of another tile's
|
|
3356
|
+
// strip: `#isExternalTabDrag` tests `TAB_MIME` plus whatever the
|
|
3357
|
+
// host names here, and defers while its own reorder is running.
|
|
3358
|
+
externalTabMimes: [TILE_TAB_MIME],
|
|
3359
|
+
onDropFromOtherPane: () => {
|
|
3360
|
+
const src = this._tabDrag;
|
|
3361
|
+
this._onTabDragEnd();
|
|
3362
|
+
if (!src || src.leafId === leafId) return;
|
|
3363
|
+
this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
|
|
3364
|
+
idx: src.idx,
|
|
3365
|
+
target: { leafId, mode: "tab", toIdx: -1 }
|
|
3366
|
+
});
|
|
3367
|
+
},
|
|
3368
|
+
onActivate: (key) => {
|
|
3369
|
+
const idx = _tabKeyIndex(key);
|
|
3370
|
+
if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, "switch", { idx });
|
|
3371
|
+
},
|
|
3372
|
+
onClose: (key) => {
|
|
3373
|
+
const idx = _tabKeyIndex(key);
|
|
3374
|
+
if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, "close", { idx });
|
|
3375
|
+
},
|
|
3376
|
+
// DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and the tree moves ONE
|
|
3377
|
+
// tab at a time (`TileTree.moveLeafTab(leafId, from, to)`). They
|
|
3378
|
+
// reconcile because a drag only ever moves one element: every other
|
|
3379
|
+
// key shifts by exactly one place, so the element that travelled
|
|
3380
|
+
// furthest between the two orders IS the one that was dragged.
|
|
3381
|
+
onReorder: (order) => {
|
|
3382
|
+
const leaf = this.tree.get(leafId);
|
|
3383
|
+
const count = (leaf?.tabs || []).length;
|
|
3384
|
+
if (!Array.isArray(order) || order.length !== count) return;
|
|
3385
|
+
let from = -1;
|
|
3386
|
+
let to = -1;
|
|
3387
|
+
let furthest = 0;
|
|
3388
|
+
order.forEach((key, newIdx) => {
|
|
3389
|
+
const oldIdx = _tabKeyIndex(key);
|
|
3390
|
+
if (oldIdx < 0) return;
|
|
3391
|
+
const travelled = Math.abs(newIdx - oldIdx);
|
|
3392
|
+
if (travelled > furthest) {
|
|
3393
|
+
furthest = travelled;
|
|
3394
|
+
from = oldIdx;
|
|
3395
|
+
to = newIdx;
|
|
3396
|
+
}
|
|
3397
|
+
});
|
|
3398
|
+
if (from < 0 || from === to) return;
|
|
3399
|
+
this.ctx.onLeafTabAction?.(leafId, "move", { from, to });
|
|
3400
|
+
}
|
|
3401
|
+
};
|
|
3402
|
+
}
|
|
3403
|
+
/** The `bottom` layout — the framework's own strip, unchanged. Hamburger
|
|
3404
|
+
* button at the start, then one trapezoid-shaped tab per stored tab spec. */
|
|
3405
|
+
_renderBottomTabBar(leaf, entry, tabs) {
|
|
3406
|
+
const bar = entry.tabBarEl;
|
|
2770
3407
|
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
2771
3408
|
bar.innerHTML = `
|
|
2772
3409
|
<button type="button" class="twm-leaf__tab-hamburger"
|
|
2773
3410
|
data-action="tab-menu"
|
|
2774
|
-
title="
|
|
3411
|
+
title="Show open tabs" aria-label="Show open tabs">
|
|
2775
3412
|
<span class="material-symbols-outlined">menu</span>
|
|
2776
3413
|
</button>
|
|
2777
3414
|
<ol class="twm-leaf__tabs" role="tablist">
|
|
@@ -2834,6 +3471,11 @@ var TileRenderer = class {
|
|
|
2834
3471
|
ev.dataTransfer.setData("text/plain", String(dragFromIdx));
|
|
2835
3472
|
} catch {
|
|
2836
3473
|
}
|
|
3474
|
+
this._beginTabDrag(ev, leaf.id, dragFromIdx, li);
|
|
3475
|
+
});
|
|
3476
|
+
li.addEventListener("dragend", () => {
|
|
3477
|
+
dragFromIdx = null;
|
|
3478
|
+
this._onTabDragEnd();
|
|
2837
3479
|
});
|
|
2838
3480
|
li.addEventListener("dragover", (ev) => {
|
|
2839
3481
|
if (dragFromIdx == null) return;
|
|
@@ -2854,6 +3496,189 @@ var TileRenderer = class {
|
|
|
2854
3496
|
dragFromIdx = null;
|
|
2855
3497
|
});
|
|
2856
3498
|
});
|
|
3499
|
+
const foreign = (ev) => !!this._tabDrag && this._tabDrag.leafId !== leaf.id && _isTileTabDrag(ev);
|
|
3500
|
+
if (bar.__twmBarDropBound) return;
|
|
3501
|
+
bar.__twmBarDropBound = true;
|
|
3502
|
+
bar.addEventListener("dragover", (ev) => {
|
|
3503
|
+
if (!foreign(ev)) return;
|
|
3504
|
+
ev.preventDefault();
|
|
3505
|
+
try {
|
|
3506
|
+
ev.dataTransfer.dropEffect = "move";
|
|
3507
|
+
} catch {
|
|
3508
|
+
}
|
|
3509
|
+
this._clearTileDropZone();
|
|
3510
|
+
bar.classList.add("twm-leaf__tabbar--drop-target");
|
|
3511
|
+
});
|
|
3512
|
+
bar.addEventListener("dragleave", (ev) => {
|
|
3513
|
+
if (!bar.contains(ev.relatedTarget)) {
|
|
3514
|
+
bar.classList.remove("twm-leaf__tabbar--drop-target");
|
|
3515
|
+
}
|
|
3516
|
+
});
|
|
3517
|
+
bar.addEventListener("drop", (ev) => {
|
|
3518
|
+
if (!foreign(ev)) return;
|
|
3519
|
+
ev.preventDefault();
|
|
3520
|
+
bar.classList.remove("twm-leaf__tabbar--drop-target");
|
|
3521
|
+
const src = this._tabDrag;
|
|
3522
|
+
this._onTabDragEnd();
|
|
3523
|
+
this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
|
|
3524
|
+
idx: src.idx,
|
|
3525
|
+
target: { leafId: leaf.id, mode: "tab", toIdx: -1 }
|
|
3526
|
+
});
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
// ══ C33. THE TAB DRAG ═══════════════════════════════════════════════
|
|
3530
|
+
/** Take the identity of the tab now being carried, and mark it.
|
|
3531
|
+
*
|
|
3532
|
+
* `seedPlainText` is for the CHROME source only. The two strips already
|
|
3533
|
+
* set `text/plain` themselves — `DragReorder._start` writes the reorder
|
|
3534
|
+
* key, the bottom strip writes the index — and overwriting either would
|
|
3535
|
+
* hand `NotebookTabBar.#onStripDrop`'s `getData(TAB_MIME) ||
|
|
3536
|
+
* getData('text/plain')` fallback a number where it expects a key. The
|
|
3537
|
+
* chrome has no such writer and Firefox refuses to begin a drag with an
|
|
3538
|
+
* empty `dataTransfer`, so it supplies its own. */
|
|
3539
|
+
_beginTabDrag(ev, leafId, idx, el, { seedPlainText = false } = {}) {
|
|
3540
|
+
this._tabDrag = { leafId, idx, el: el || null };
|
|
3541
|
+
this._tabDropProbe = null;
|
|
3542
|
+
try {
|
|
3543
|
+
ev.dataTransfer.effectAllowed = "move";
|
|
3544
|
+
ev.dataTransfer.setData(TILE_TAB_MIME, "1");
|
|
3545
|
+
if (seedPlainText) ev.dataTransfer.setData("text/plain", String(idx));
|
|
3546
|
+
} catch {
|
|
3547
|
+
}
|
|
3548
|
+
el?.classList?.add("dragging");
|
|
3549
|
+
}
|
|
3550
|
+
/**
|
|
3551
|
+
* Arm — or refuse — the tile under the pointer.
|
|
3552
|
+
*
|
|
3553
|
+
* NOTHING HERE MAY RENDER. `render()` clears the root, which detaches the
|
|
3554
|
+
* element the browser is dragging, and the browser cancels the gesture the
|
|
3555
|
+
* moment that happens. The DOM afterwards reads perfectly correct, which is
|
|
3556
|
+
* what makes this failure so hard to see; it is the drag-and-drop cousin of
|
|
3557
|
+
* the `mousedown`-repaint defect recorded five times against the taskbar.
|
|
3558
|
+
*
|
|
3559
|
+
* `preventDefault()` is not decoration either: without it the browser
|
|
3560
|
+
* refuses the drop outright and `drop` never fires, which reads exactly
|
|
3561
|
+
* like a broken handler.
|
|
3562
|
+
*/
|
|
3563
|
+
_onTabDragOver(e) {
|
|
3564
|
+
const src = this._tabDrag;
|
|
3565
|
+
if (!src || !_isTileTabDrag(e)) return;
|
|
3566
|
+
if (e.target?.closest?.(".twm-leaf__tabbar")) {
|
|
3567
|
+
this._clearTileDropZone();
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
const probe = this.ctx.wm?.tabDropProbe?.(e, { sourceLeafId: src.leafId }) || null;
|
|
3571
|
+
this._tabDropProbe = probe;
|
|
3572
|
+
if (!probe) {
|
|
3573
|
+
this._clearTabDropZone();
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
e.preventDefault();
|
|
3577
|
+
try {
|
|
3578
|
+
e.dataTransfer.dropEffect = "move";
|
|
3579
|
+
} catch {
|
|
3580
|
+
}
|
|
3581
|
+
for (const [leafId, entry] of this._leafCache) {
|
|
3582
|
+
entry.wrapEl.classList.toggle("twm-leaf--drop-target", leafId === probe.leafId);
|
|
3583
|
+
}
|
|
3584
|
+
this._showTabDropPreview(probe.rect);
|
|
3585
|
+
}
|
|
3586
|
+
/** Release. The zone that was ARMED is the zone that runs — the stashed
|
|
3587
|
+
* probe rather than a fresh one — because C15's rule is that the rectangle
|
|
3588
|
+
* drawn during the drag is the rectangle the drop delivers, and a pointer
|
|
3589
|
+
* one pixel outside the band at release must not quietly mean something
|
|
3590
|
+
* else. */
|
|
3591
|
+
_onTabDrop(e) {
|
|
3592
|
+
const src = this._tabDrag;
|
|
3593
|
+
const probe = this._tabDropProbe;
|
|
3594
|
+
if (!src || !_isTileTabDrag(e)) return;
|
|
3595
|
+
if (e.target?.closest?.(".twm-leaf__tabbar")) return;
|
|
3596
|
+
e.preventDefault();
|
|
3597
|
+
this._onTabDragEnd();
|
|
3598
|
+
if (!probe) return;
|
|
3599
|
+
this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
|
|
3600
|
+
idx: src.idx,
|
|
3601
|
+
target: {
|
|
3602
|
+
leafId: probe.leafId,
|
|
3603
|
+
mode: probe.mode,
|
|
3604
|
+
// The same derivation `_snapCommit` applies to a window drop
|
|
3605
|
+
// (`wm.js`, the `_dock` literal) — one reading of a side, so a
|
|
3606
|
+
// tab and a window cannot land on opposite halves of one edge.
|
|
3607
|
+
dir: probe.side === "left" || probe.side === "right" ? "h" : "v",
|
|
3608
|
+
before: probe.side === "left" || probe.side === "top",
|
|
3609
|
+
toIdx: -1
|
|
3610
|
+
}
|
|
3611
|
+
});
|
|
3612
|
+
}
|
|
3613
|
+
/** Leaving the root entirely disarms, and only that. The drag is still
|
|
3614
|
+
* live — it may come back — so `_tabDrag` survives and only the painting
|
|
3615
|
+
* goes. */
|
|
3616
|
+
_onTabDragLeave(e) {
|
|
3617
|
+
if (!this._tabDrag) return;
|
|
3618
|
+
if (this.root.contains(e.relatedTarget)) return;
|
|
3619
|
+
this._clearTabDropZone();
|
|
3620
|
+
this._tabDropProbe = null;
|
|
3621
|
+
}
|
|
3622
|
+
/** The only guaranteed end of a drag. Escape produces this and no `drop`;
|
|
3623
|
+
* so does a release over a target that refused. Idempotent, because the
|
|
3624
|
+
* drop path calls it too and `dragend` still arrives afterwards. */
|
|
3625
|
+
_onTabDragEnd() {
|
|
3626
|
+
this._tabDrag?.el?.classList?.remove("dragging");
|
|
3627
|
+
this._tabDrag = null;
|
|
3628
|
+
this._tabDropProbe = null;
|
|
3629
|
+
this._clearTabDropZone();
|
|
3630
|
+
}
|
|
3631
|
+
/** The TILE zone only — the outlined pane and the preview rectangle. Split
|
|
3632
|
+
* out from the whole because a strip that has just armed itself must not
|
|
3633
|
+
* be disarmed by the root handler running behind it. */
|
|
3634
|
+
_clearTileDropZone() {
|
|
3635
|
+
for (const [, entry] of this._leafCache) {
|
|
3636
|
+
entry.wrapEl.classList.remove("twm-leaf--drop-target");
|
|
3637
|
+
}
|
|
3638
|
+
this._clearTabDropPreview();
|
|
3639
|
+
}
|
|
3640
|
+
/** Everything: the tile zone and both strips'. The end of a gesture, where
|
|
3641
|
+
* nothing may be left painted. */
|
|
3642
|
+
_clearTabDropZone() {
|
|
3643
|
+
this._clearTileDropZone();
|
|
3644
|
+
for (const [, entry] of this._leafCache) {
|
|
3645
|
+
entry.tabBarEl?.classList?.remove("twm-leaf__tabbar--drop-target");
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
/** The rectangle a release would fill.
|
|
3649
|
+
*
|
|
3650
|
+
* IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
|
|
3651
|
+
* stylesheet rules (`css/base.css`, `.twm-snap-preview`) — so a tab drop
|
|
3652
|
+
* and a window drop cannot come to disagree about what a drop looks like.
|
|
3653
|
+
* `--viewport` is what makes `position: fixed` apply, and that is required
|
|
3654
|
+
* rather than cosmetic: the rectangle came from `getBoundingClientRect` on
|
|
3655
|
+
* a leaf, which speaks viewport pixels.
|
|
3656
|
+
*
|
|
3657
|
+
* Parented to `document.body` and not to the root, for R13's reason:
|
|
3658
|
+
* `render()`'s `innerHTML = ''` takes every direct child of the root, and
|
|
3659
|
+
* a repaint during a drag needs nothing more exotic than the drag itself. */
|
|
3660
|
+
_showTabDropPreview(rect) {
|
|
3661
|
+
if (!rect) {
|
|
3662
|
+
this._clearTabDropPreview();
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
if (!this._tabDropPreviewEl) {
|
|
3666
|
+
const el2 = document.createElement("div");
|
|
3667
|
+
el2.className = "twm-snap-preview twm-snap-preview--viewport";
|
|
3668
|
+
el2.setAttribute("aria-hidden", "true");
|
|
3669
|
+
this._tabDropPreviewEl = el2;
|
|
3670
|
+
}
|
|
3671
|
+
const el = this._tabDropPreviewEl;
|
|
3672
|
+
Object.assign(el.style, {
|
|
3673
|
+
left: `${rect.left ?? rect.x}px`,
|
|
3674
|
+
top: `${rect.top ?? rect.y}px`,
|
|
3675
|
+
width: `${rect.width}px`,
|
|
3676
|
+
height: `${rect.height}px`
|
|
3677
|
+
});
|
|
3678
|
+
if (el.parentNode !== document.body) document.body.appendChild(el);
|
|
3679
|
+
}
|
|
3680
|
+
_clearTabDropPreview() {
|
|
3681
|
+
this._tabDropPreviewEl?.remove();
|
|
2857
3682
|
}
|
|
2858
3683
|
_updateFocusClasses() {
|
|
2859
3684
|
const focused = this.tree.focusedLeafId;
|
|
@@ -2867,6 +3692,42 @@ var TileRenderer = class {
|
|
|
2867
3692
|
leafEl(leafId) {
|
|
2868
3693
|
return this._leafCache.get(leafId)?.wrapEl || null;
|
|
2869
3694
|
}
|
|
3695
|
+
/** C20, extended. The `chrome` veto object the mounted content declared —
|
|
3696
|
+
* `{ promote: false, close: false }` — or null when the leaf is not
|
|
3697
|
+
* rendered or its content declared nothing.
|
|
3698
|
+
*
|
|
3699
|
+
* IT EXISTS BECAUSE A VETO PAINTED ON A BUTTON IS NOT A VETO. C20 landed
|
|
3700
|
+
* as `_vetoStructuralActions`, which removes the button from this strip —
|
|
3701
|
+
* and a removed button is only the door the CONTENT can see. The verb has
|
|
3702
|
+
* three other doors: the tile's right-click menu (`shell.js`'s "Float this
|
|
3703
|
+
* pane as a window"), the chrome pull-down, and the chrome's double-click.
|
|
3704
|
+
* All three reach `WindowManager.floatPane` without passing this file, so
|
|
3705
|
+
* a master tile that declared itself unfloatable was floated by any of
|
|
3706
|
+
* them — reproduced: the ground pane floats, every window standing on it
|
|
3707
|
+
* is force-closed, and the pane is re-seeded WITHOUT its `canvas` prop.
|
|
3708
|
+
*
|
|
3709
|
+
* So the WM asks the renderer what the content said, and enforces it in
|
|
3710
|
+
* `_floatableLeaf` where every door already converges. The renderer stays
|
|
3711
|
+
* the only place that knows what was mounted; the WM stays the only place
|
|
3712
|
+
* that decides whether a verb runs. `closeFocused` now reads `close` the
|
|
3713
|
+
* same way, for the same reason and after the same defect: Alt+W, the tile
|
|
3714
|
+
* context menu and the tab strip's × all closed a pane whose own button
|
|
3715
|
+
* was greyed out with a tooltip saying it could not be.
|
|
3716
|
+
*
|
|
3717
|
+
* ══ THIS RETURNS THE FACTORY'S LIVE OBJECT, AND THAT IS A CONTRACT ══
|
|
3718
|
+
*
|
|
3719
|
+
* Not a copy and not a snapshot. `chrome` is READ ONCE, at mount — a
|
|
3720
|
+
* repaint of a cached leaf re-reads only the title and the glyph — so a
|
|
3721
|
+
* veto whose ANSWER CHANGES over the life of the tile must be kept up to
|
|
3722
|
+
* date by the content that stated it, by mutating the object it returned.
|
|
3723
|
+
* Tables' ground pane is exactly that case: its close is refused only
|
|
3724
|
+
* while it is the last content pane, and it re-syncs on `wm:changed`.
|
|
3725
|
+
* Repainting the button alone is not enough now that a verb consults this
|
|
3726
|
+
* — a stale `{disabled: true}` refuses a close every affordance on screen
|
|
3727
|
+
* says is available, which is the same class of lie as a dead control. */
|
|
3728
|
+
leafChrome(leafId) {
|
|
3729
|
+
return this._leafCache.get(leafId)?.content?.chrome || null;
|
|
3730
|
+
}
|
|
2870
3731
|
// ── Drag-resize ────────────────────────────────────────────────
|
|
2871
3732
|
_onMouseDown(e) {
|
|
2872
3733
|
const splitter = e.target.closest(".twm-splitter");
|
|
@@ -2937,6 +3798,162 @@ function _esc6(s) {
|
|
|
2937
3798
|
"'": "'"
|
|
2938
3799
|
})[c]);
|
|
2939
3800
|
}
|
|
3801
|
+
function _paintLeafIcon(iconEl, leaf, content, ctx) {
|
|
3802
|
+
if (!iconEl) return;
|
|
3803
|
+
const kind = leaf?.content?.kind;
|
|
3804
|
+
const name = content?.icon || (kind ? ctx?.taxonomy?.meta?.(kind)?.icon : null);
|
|
3805
|
+
iconEl.textContent = name || "";
|
|
3806
|
+
iconEl.hidden = !name;
|
|
3807
|
+
}
|
|
3808
|
+
function _paintContentActions(hostEl, specs) {
|
|
3809
|
+
hostEl.innerHTML = "";
|
|
3810
|
+
if (!Array.isArray(specs) || specs.length === 0) {
|
|
3811
|
+
hostEl.hidden = true;
|
|
3812
|
+
return;
|
|
3813
|
+
}
|
|
3814
|
+
hostEl.hidden = false;
|
|
3815
|
+
for (const spec of specs) {
|
|
3816
|
+
if (!spec || !spec.icon) continue;
|
|
3817
|
+
const btn = document.createElement("button");
|
|
3818
|
+
btn.type = "button";
|
|
3819
|
+
btn.className = "twm-leaf__btn";
|
|
3820
|
+
btn.title = spec.title || "";
|
|
3821
|
+
btn.setAttribute("aria-label", spec.title || "");
|
|
3822
|
+
btn.innerHTML = `<span class="material-symbols-outlined">${spec.icon}</span>`;
|
|
3823
|
+
btn.addEventListener("click", (e) => {
|
|
3824
|
+
e.stopPropagation();
|
|
3825
|
+
try {
|
|
3826
|
+
spec.onClick?.();
|
|
3827
|
+
} catch (err) {
|
|
3828
|
+
console.error("[tile] action threw", err);
|
|
3829
|
+
}
|
|
3830
|
+
});
|
|
3831
|
+
hostEl.appendChild(btn);
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
function _vetoStructuralActions(hostEl, chrome) {
|
|
3835
|
+
if (!chrome) return;
|
|
3836
|
+
for (const [action, rule] of Object.entries(chrome)) {
|
|
3837
|
+
const btn = hostEl.querySelector(`[data-action="${action}"]`);
|
|
3838
|
+
if (!btn) continue;
|
|
3839
|
+
if (rule === false) {
|
|
3840
|
+
btn.remove();
|
|
3841
|
+
continue;
|
|
3842
|
+
}
|
|
3843
|
+
if (rule && typeof rule === "object" && rule.disabled) {
|
|
3844
|
+
btn.disabled = true;
|
|
3845
|
+
btn.setAttribute("aria-disabled", "true");
|
|
3846
|
+
btn.classList.add("twm-leaf__btn--disabled");
|
|
3847
|
+
if (rule.title) btn.title = rule.title;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
// src/tiling/tab_strip.js
|
|
3853
|
+
function tabKey(idx) {
|
|
3854
|
+
return `builtin://tab/${idx}`;
|
|
3855
|
+
}
|
|
3856
|
+
function tabKeyIndex(key) {
|
|
3857
|
+
const n = Number(String(key ?? "").replace("builtin://tab/", ""));
|
|
3858
|
+
return Number.isInteger(n) && n >= 0 ? n : -1;
|
|
3859
|
+
}
|
|
3860
|
+
function reorderToMove(order, count) {
|
|
3861
|
+
if (!Array.isArray(order) || order.length !== count) return null;
|
|
3862
|
+
let from = -1;
|
|
3863
|
+
let to = -1;
|
|
3864
|
+
let furthest = 0;
|
|
3865
|
+
order.forEach((key, newIdx) => {
|
|
3866
|
+
const oldIdx = tabKeyIndex(key);
|
|
3867
|
+
if (oldIdx < 0) return;
|
|
3868
|
+
const travelled = Math.abs(newIdx - oldIdx);
|
|
3869
|
+
if (travelled > furthest) {
|
|
3870
|
+
furthest = travelled;
|
|
3871
|
+
from = oldIdx;
|
|
3872
|
+
to = newIdx;
|
|
3873
|
+
}
|
|
3874
|
+
});
|
|
3875
|
+
if (from < 0 || from === to) return null;
|
|
3876
|
+
return { from, to };
|
|
3877
|
+
}
|
|
3878
|
+
function createTabStrip({ hostEl, taxonomy = null, onAction }) {
|
|
3879
|
+
const host = document.createElement("div");
|
|
3880
|
+
hostEl.appendChild(host);
|
|
3881
|
+
const strip = new NotebookTabBar();
|
|
3882
|
+
let tabs = [];
|
|
3883
|
+
strip.mount(host, {
|
|
3884
|
+
onActivate: (key) => {
|
|
3885
|
+
const idx = tabKeyIndex(key);
|
|
3886
|
+
if (idx >= 0) onAction?.("switch", { idx });
|
|
3887
|
+
},
|
|
3888
|
+
onClose: (key) => {
|
|
3889
|
+
const idx = tabKeyIndex(key);
|
|
3890
|
+
if (idx >= 0) onAction?.("close", { idx });
|
|
3891
|
+
},
|
|
3892
|
+
onReorder: (order) => {
|
|
3893
|
+
const move = reorderToMove(order, tabs.length);
|
|
3894
|
+
if (move) onAction?.("move", move);
|
|
3895
|
+
}
|
|
3896
|
+
// Everything else the component offers that a tab here cannot honour is
|
|
3897
|
+
// deliberately absent rather than stubbed — `onRename` is refused by the
|
|
3898
|
+
// `builtin://` key, and the rest (`onDuplicate`, `onSplitRight`,
|
|
3899
|
+
// `onRevealInExplorer`, `onCloseAll`, …) are only ever reached from the
|
|
3900
|
+
// context menu suppressed below.
|
|
3901
|
+
});
|
|
3902
|
+
const onContextMenu = (ev) => {
|
|
3903
|
+
const tabEl = ev.target.closest?.(".tab");
|
|
3904
|
+
if (!tabEl) return;
|
|
3905
|
+
ev.preventDefault();
|
|
3906
|
+
ev.stopPropagation();
|
|
3907
|
+
const idx = Array.prototype.indexOf.call(host.querySelectorAll(".tab"), tabEl);
|
|
3908
|
+
if (idx < 0) return;
|
|
3909
|
+
onAction?.("menu", { idx, x: ev.clientX, y: ev.clientY });
|
|
3910
|
+
};
|
|
3911
|
+
host.addEventListener("contextmenu", onContextMenu, true);
|
|
3912
|
+
const repaint = () => {
|
|
3913
|
+
host.querySelectorAll(".tab").forEach((el, i) => {
|
|
3914
|
+
const spec = tabs[i];
|
|
3915
|
+
if (!spec) return;
|
|
3916
|
+
el.title = spec.title || spec.kind || "";
|
|
3917
|
+
const icon = spec.kind ? taxonomy?.meta?.(spec.kind)?.icon : null;
|
|
3918
|
+
const glyph = el.querySelector(".tab-icon");
|
|
3919
|
+
if (!glyph) return;
|
|
3920
|
+
if (icon) {
|
|
3921
|
+
glyph.textContent = icon;
|
|
3922
|
+
glyph.hidden = false;
|
|
3923
|
+
} else glyph.hidden = true;
|
|
3924
|
+
});
|
|
3925
|
+
};
|
|
3926
|
+
return {
|
|
3927
|
+
/** @param {Array<{kind: string, props?: object, title?: string, dirty?: boolean}>} next */
|
|
3928
|
+
update(next, activeIdx) {
|
|
3929
|
+
tabs = Array.isArray(next) ? next : [];
|
|
3930
|
+
const active = Math.max(0, Math.min(tabs.length - 1, activeIdx || 0));
|
|
3931
|
+
strip.update(
|
|
3932
|
+
tabs.map((t, i) => ({
|
|
3933
|
+
filePath: tabKey(i),
|
|
3934
|
+
label: t.title || t.kind || "",
|
|
3935
|
+
fileType: t.kind || "unknown",
|
|
3936
|
+
// Nothing sets `dirty` on a tab spec today, so the dot is
|
|
3937
|
+
// never drawn. Read anyway, because the day a tab can say it
|
|
3938
|
+
// holds unsaved work this is where it says it, and the
|
|
3939
|
+
// alternative is a second place to remember.
|
|
3940
|
+
isDirty: !!t.dirty
|
|
3941
|
+
})),
|
|
3942
|
+
tabKey(active)
|
|
3943
|
+
);
|
|
3944
|
+
repaint();
|
|
3945
|
+
},
|
|
3946
|
+
dispose() {
|
|
3947
|
+
host.removeEventListener("contextmenu", onContextMenu, true);
|
|
3948
|
+
try {
|
|
3949
|
+
strip.dispose();
|
|
3950
|
+
} catch (err) {
|
|
3951
|
+
console.error("[tab-strip] dispose threw", err);
|
|
3952
|
+
}
|
|
3953
|
+
host.remove();
|
|
3954
|
+
}
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
2940
3957
|
|
|
2941
3958
|
// src/tiling/wm.js
|
|
2942
3959
|
var PANEL_KINDS = /* @__PURE__ */ new Set(["panel:left", "panel:right", "panel:bottom"]);
|
|
@@ -2945,8 +3962,22 @@ var PANEL_TITLES = {
|
|
|
2945
3962
|
right: "Inspector",
|
|
2946
3963
|
bottom: "Console"
|
|
2947
3964
|
};
|
|
2948
|
-
var WindowManager = class {
|
|
2949
|
-
constructor({
|
|
3965
|
+
var WindowManager = class _WindowManager {
|
|
3966
|
+
constructor({
|
|
3967
|
+
rootEl,
|
|
3968
|
+
api,
|
|
3969
|
+
ctx,
|
|
3970
|
+
onChange,
|
|
3971
|
+
eventBus,
|
|
3972
|
+
host,
|
|
3973
|
+
taxonomy,
|
|
3974
|
+
events,
|
|
3975
|
+
content,
|
|
3976
|
+
panelDefaults = null,
|
|
3977
|
+
snapPromotion = false,
|
|
3978
|
+
promoteInPlace = false,
|
|
3979
|
+
tabLayout = null
|
|
3980
|
+
}) {
|
|
2950
3981
|
if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
|
|
2951
3982
|
if (!content || typeof content.mount !== "function") {
|
|
2952
3983
|
throw new Error("WindowManager: a content registry is required (createContentRegistry / createShell owns it)");
|
|
@@ -2968,18 +3999,34 @@ var WindowManager = class {
|
|
|
2968
3999
|
title: this.taxonomy.meta(kind)?.label || kind
|
|
2969
4000
|
};
|
|
2970
4001
|
};
|
|
2971
|
-
this.
|
|
4002
|
+
this.panelDefaults = panelDefaults || null;
|
|
4003
|
+
this.snapPromotion = !!snapPromotion;
|
|
4004
|
+
this.promoteInPlace = !!promoteInPlace;
|
|
4005
|
+
this._snapCtl = null;
|
|
4006
|
+
this.desktops = new DesktopManager({
|
|
4007
|
+
seed: this._rootLeaf,
|
|
4008
|
+
panelDefaults: this.panelDefaults
|
|
4009
|
+
});
|
|
2972
4010
|
this.renderer = new TileRenderer({
|
|
2973
4011
|
root: rootEl,
|
|
2974
4012
|
tree: this.desktops.active().tree,
|
|
2975
4013
|
content,
|
|
4014
|
+
/** C22. Where a multi-tab leaf draws its tabs — `'bottom'`
|
|
4015
|
+
* (the framework's own spreadsheet strip, and the DEFAULT so no
|
|
4016
|
+
* existing embedder's panes rearrange on upgrade) or `'top'`
|
|
4017
|
+
* (the editor tab bar, between the chrome and the body).
|
|
4018
|
+
* The renderer also mirrors this onto the root as
|
|
4019
|
+
* `data-twm-tabs` and watches it, so an embedder can change it
|
|
4020
|
+
* live without holding a renderer reference. */
|
|
4021
|
+
tabLayout,
|
|
2976
4022
|
ctx: {
|
|
2977
4023
|
...this.ctx,
|
|
2978
4024
|
wm: this,
|
|
2979
4025
|
onLeafAction: (leafId, action) => this._leafAction(leafId, action),
|
|
2980
4026
|
onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
|
|
2981
4027
|
},
|
|
2982
|
-
onFocusChange: () => this._notifyChange()
|
|
4028
|
+
onFocusChange: () => this._notifyChange(),
|
|
4029
|
+
onAfterRender: () => this._rehomeContainedWindows()
|
|
2983
4030
|
});
|
|
2984
4031
|
this._persistTimer = null;
|
|
2985
4032
|
this._windowToLeaf = /* @__PURE__ */ new Map();
|
|
@@ -3001,6 +4048,16 @@ var WindowManager = class {
|
|
|
3001
4048
|
});
|
|
3002
4049
|
}
|
|
3003
4050
|
}
|
|
4051
|
+
/** C22. Change the tab layout of every tile, live. Delegates to the
|
|
4052
|
+
* renderer, which moves each strip rather than rebuilding the tiles — so
|
|
4053
|
+
* nothing mounted in a tile is unmounted and no staged work is lost.
|
|
4054
|
+
*
|
|
4055
|
+
* Not persisted here: which layout a user prefers is a USER setting, and
|
|
4056
|
+
* the WM persists LAYOUT (`desktops`). An embedder that stores it does so
|
|
4057
|
+
* under its own key and passes it back as `createShell({ tabLayout })`. */
|
|
4058
|
+
setTabLayout(layout) {
|
|
4059
|
+
this.renderer.setTabLayout(layout);
|
|
4060
|
+
}
|
|
3004
4061
|
/** Emit on bus + call onChange. Use this instead of the bare callback
|
|
3005
4062
|
* so other surfaces (palette, top-bar toggles, page shortcuts) can
|
|
3006
4063
|
* subscribe through the existing event system. */
|
|
@@ -3035,7 +4092,10 @@ var WindowManager = class {
|
|
|
3035
4092
|
async load() {
|
|
3036
4093
|
const blob = await loadDesktops(this.host?.state);
|
|
3037
4094
|
if (blob) {
|
|
3038
|
-
this.desktops = DesktopManager.deserialize(blob, {
|
|
4095
|
+
this.desktops = DesktopManager.deserialize(blob, {
|
|
4096
|
+
seed: this._rootLeaf,
|
|
4097
|
+
panelDefaults: this.panelDefaults
|
|
4098
|
+
});
|
|
3039
4099
|
this.renderer.tree = this.desktops.active().tree;
|
|
3040
4100
|
}
|
|
3041
4101
|
for (const d of this.desktops.desktops) {
|
|
@@ -3160,7 +4220,9 @@ var WindowManager = class {
|
|
|
3160
4220
|
updateActiveTabProps(leafId, patch) {
|
|
3161
4221
|
const tree = this._tree();
|
|
3162
4222
|
if (!tree?.updateActiveTabProps) return;
|
|
4223
|
+
const expected = this.renderer?.leafKey?.(leafId) ?? void 0;
|
|
3163
4224
|
tree.updateActiveTabProps(leafId, patch);
|
|
4225
|
+
this.renderer?.rebaselineLeaf?.(leafId, expected);
|
|
3164
4226
|
this._persist();
|
|
3165
4227
|
}
|
|
3166
4228
|
/** Navigate inside the current tab — preserves every other tab in
|
|
@@ -3233,7 +4295,7 @@ var WindowManager = class {
|
|
|
3233
4295
|
this._notifyChange();
|
|
3234
4296
|
}
|
|
3235
4297
|
/** Replace a managed window's content in place. Tears down the
|
|
3236
|
-
* previous mount, mounts the new kind into the same
|
|
4298
|
+
* previous mount, mounts the new kind into the same body element,
|
|
3237
4299
|
* and updates the window's title. */
|
|
3238
4300
|
openInWindow(winId, kind, props = {}) {
|
|
3239
4301
|
const rec = this._windowToLeaf.get(winId);
|
|
@@ -3245,10 +4307,11 @@ var WindowManager = class {
|
|
|
3245
4307
|
rec.mountInfo?.destroy?.();
|
|
3246
4308
|
} catch {
|
|
3247
4309
|
}
|
|
3248
|
-
rec.
|
|
4310
|
+
const host = rec.bodyEl || rec.contentEl;
|
|
4311
|
+
host.innerHTML = "";
|
|
3249
4312
|
const mountInfo = this.content.mount(
|
|
3250
4313
|
kind,
|
|
3251
|
-
|
|
4314
|
+
host,
|
|
3252
4315
|
props,
|
|
3253
4316
|
{ ...this.ctx, wm: this, windowId: winId }
|
|
3254
4317
|
);
|
|
@@ -3258,12 +4321,14 @@ var WindowManager = class {
|
|
|
3258
4321
|
props: { ...props || {} },
|
|
3259
4322
|
title: mountInfo?.title || kind
|
|
3260
4323
|
};
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
4324
|
+
const tab = (rec.tabs || [])[rec.activeTabIdx];
|
|
4325
|
+
if (tab) {
|
|
4326
|
+
tab.kind = kind;
|
|
4327
|
+
tab.props = { ...props || {} };
|
|
4328
|
+
tab.title = rec.original.title;
|
|
4329
|
+
rec.strip?.update(rec.tabs, rec.activeTabIdx);
|
|
4330
|
+
}
|
|
4331
|
+
this._setWindowTitle(rec, rec.original.title);
|
|
3267
4332
|
this._persist();
|
|
3268
4333
|
this._notifyChange("window-content-changed");
|
|
3269
4334
|
}
|
|
@@ -3300,11 +4365,22 @@ var WindowManager = class {
|
|
|
3300
4365
|
const tree = this._tree();
|
|
3301
4366
|
const focused = tree.focusedLeafId;
|
|
3302
4367
|
if (!focused) return;
|
|
3303
|
-
tree.split(focused, dir);
|
|
4368
|
+
const newId = tree.split(focused, dir);
|
|
4369
|
+
if (newId) this._seedHome(tree, newId);
|
|
3304
4370
|
this.renderer.render();
|
|
3305
4371
|
this._persist();
|
|
3306
4372
|
this._notifyChange();
|
|
3307
4373
|
}
|
|
4374
|
+
/** Seed a leaf with the default HOME content (the taxonomy root kind).
|
|
4375
|
+
* Used to keep the never-empty-tile invariant: the pane freed by a
|
|
4376
|
+
* split, or emptied when its last tab floats into a window, is
|
|
4377
|
+
* re-homed instead of destroyed or left blank. Embedder-agnostic —
|
|
4378
|
+
* the HOME kind comes from the taxonomy, exactly like a fresh
|
|
4379
|
+
* desktop's seed leaf. */
|
|
4380
|
+
_seedHome(tree, leafId) {
|
|
4381
|
+
const seed = this._rootLeaf();
|
|
4382
|
+
tree.setLeafContent(leafId, seed.content, seed.title);
|
|
4383
|
+
}
|
|
3308
4384
|
/** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
|
|
3309
4385
|
* created sibling — the "open this content in a new split" primitive
|
|
3310
4386
|
* behind the code-pane split buttons, the per-pane context menu and
|
|
@@ -3368,6 +4444,8 @@ var WindowManager = class {
|
|
|
3368
4444
|
if (!focusedId) return;
|
|
3369
4445
|
const leaf = tree.get(focusedId);
|
|
3370
4446
|
const kind = leaf?.content?.kind;
|
|
4447
|
+
const closeChrome = this.renderer?.leafChrome?.(focusedId)?.close;
|
|
4448
|
+
if (closeChrome === false || closeChrome?.disabled === true) return;
|
|
3371
4449
|
if (kind === PLACEHOLDER_KIND) {
|
|
3372
4450
|
const winId = leaf.content?.props?.windowId;
|
|
3373
4451
|
const rec = winId ? this._windowToLeaf.get(winId) : null;
|
|
@@ -3387,6 +4465,10 @@ var WindowManager = class {
|
|
|
3387
4465
|
} else {
|
|
3388
4466
|
tree.close(focusedId);
|
|
3389
4467
|
}
|
|
4468
|
+
if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
|
|
4469
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
4470
|
+
if (spawned) this._seedHome(tree, spawned);
|
|
4471
|
+
}
|
|
3390
4472
|
this._canonicalize(tree, this.desktops.active());
|
|
3391
4473
|
this.renderer.render();
|
|
3392
4474
|
this._persist();
|
|
@@ -3464,30 +4546,228 @@ var WindowManager = class {
|
|
|
3464
4546
|
}
|
|
3465
4547
|
}
|
|
3466
4548
|
// ── Tile <-> Managed window ─────────────────────────────────────
|
|
3467
|
-
/**
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
4549
|
+
/** Float the focused pane into a managed window.
|
|
4550
|
+
*
|
|
4551
|
+
* R8. THE WHOLE PANE, not its active tab. This used to float one tab and
|
|
4552
|
+
* leave the rest behind, which made "float this pane as a window" a
|
|
4553
|
+
* different verb from the one its own tooltip named: a pane with three
|
|
4554
|
+
* tables in it became a window holding one and a pane holding two, and
|
|
4555
|
+
* nothing on screen said which of the three you were going to get. The
|
|
4556
|
+
* product owner's words are the whole specification — *"to window includes
|
|
4557
|
+
* the tab-strip"* — so the tabs travel with the pane and the strip is
|
|
4558
|
+
* rendered INSIDE the window.
|
|
4559
|
+
*
|
|
4560
|
+
* Floating ONE tab is still available and is still wanted; it moved to
|
|
4561
|
+
* where it was always meant to be, which is the right-click menu on the
|
|
4562
|
+
* tab itself (R9, `floatTabAsWindow`). A verb that acts on one tab belongs
|
|
4563
|
+
* on that tab, not on the pane's chrome.
|
|
4564
|
+
*
|
|
4565
|
+
* Never-empty-tile invariant, unchanged: the emptied pane is RE-SEEDED
|
|
4566
|
+
* with the default HOME content rather than destroyed, so the grid never
|
|
4567
|
+
* ends up with a missing or blank main tile. */
|
|
3471
4568
|
toggleManagedFocused() {
|
|
3472
4569
|
const tree = this._tree();
|
|
3473
4570
|
const focused = tree.focused();
|
|
3474
|
-
if (!focused
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
4571
|
+
if (!focused) return null;
|
|
4572
|
+
return this.floatPane(focused.id);
|
|
4573
|
+
}
|
|
4574
|
+
/** R8. Float a pane — every tab, with the strip — into a managed window.
|
|
4575
|
+
* Returns the window id, or null when the leaf is not something that can
|
|
4576
|
+
* be floated. */
|
|
4577
|
+
floatPane(leafId) {
|
|
4578
|
+
const leaf = this._floatableLeaf(leafId);
|
|
4579
|
+
if (!leaf) return null;
|
|
4580
|
+
const tabs = _leafTabSpecs(leaf);
|
|
4581
|
+
const active = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
4582
|
+
return this._promote(leafId, tabs, active, { wholePane: true });
|
|
4583
|
+
}
|
|
4584
|
+
/** R9. Float ONE tab of a pane into a managed window, leaving its siblings
|
|
4585
|
+
* where they are — which is exactly what `toggleManagedFocused` did before
|
|
4586
|
+
* R8, so the behaviour survives, it just moved to the gesture that names
|
|
4587
|
+
* it. The tab's right-click menu is the only caller. */
|
|
4588
|
+
floatTabAsWindow(leafId, idx) {
|
|
4589
|
+
const leaf = this._floatableLeaf(leafId);
|
|
4590
|
+
if (!leaf) return null;
|
|
4591
|
+
const tabs = _leafTabSpecs(leaf);
|
|
4592
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return null;
|
|
4593
|
+
return this._promote(leafId, [tabs[idx]], 0, { wholePane: false, tabIdx: idx });
|
|
4594
|
+
}
|
|
4595
|
+
/**
|
|
4596
|
+
* C33. MOVE ONE TAB INTO ANOTHER TILE — the same verb as `floatTabAsWindow`
|
|
4597
|
+
* above with a TILE as the destination instead of a window, which is why it
|
|
4598
|
+
* sits beside it.
|
|
4599
|
+
*
|
|
4600
|
+
* ══ THE ORDER IS LOAD-BEARING ═══════════════════════════════════════
|
|
4601
|
+
*
|
|
4602
|
+
* Four steps, and three of them are in this order for a reason that a
|
|
4603
|
+
* plausible-looking rewrite would destroy:
|
|
4604
|
+
*
|
|
4605
|
+
* (a) READ THE TAB SPEC FIRST. `fromIdx` is an ARRAY INDEX — the only
|
|
4606
|
+
* identity a tile tab has (`tile_renderer._tabKey`) — so it is stale
|
|
4607
|
+
* the instant anything splices a tab list. Everything below works
|
|
4608
|
+
* from the copy taken here.
|
|
4609
|
+
*
|
|
4610
|
+
* (b) SPLIT BEFORE REMOVING. When the destination IS the source pane —
|
|
4611
|
+
* "tear this tab off into a split beside its siblings" — removing
|
|
4612
|
+
* first can empty that pane and send it through `_seedHome`, so the
|
|
4613
|
+
* split would then be splitting a freshly seeded ground rather than
|
|
4614
|
+
* the pane the preview drew. Splitting first cannot go wrong in the
|
|
4615
|
+
* other direction: `tree.split` never touches tabs.
|
|
4616
|
+
*
|
|
4617
|
+
* (c) THE MOVE ITSELF IS ONE TREE CALL for `tab` — `moveTabToLeaf`, which
|
|
4618
|
+
* exists so the tab cannot be in flight between two mutations — and
|
|
4619
|
+
* remove-then-`setLeafContent` for `fill`/`split`, where the
|
|
4620
|
+
* destination is ground or brand new and REPLACING is the point.
|
|
4621
|
+
*
|
|
4622
|
+
* (d) RE-SEED AND MERGE, exactly as `_promote` does when the last tab
|
|
4623
|
+
* leaves a pane (`_seedHome` then `_mergeStartTiles`). A pane is
|
|
4624
|
+
* never left blank, and two grounds never end up side by side with a
|
|
4625
|
+
* splitter between them for no reason.
|
|
4626
|
+
*
|
|
4627
|
+
* ══ `wm:tab-moved` IS EMITTED BEFORE THE REPAINT ════════════════════
|
|
4628
|
+
*
|
|
4629
|
+
* An embedder that keys live content by leaf id — the Tables grid registry
|
|
4630
|
+
* does, on `(leaf, table)`, because a DOM element exists in exactly one
|
|
4631
|
+
* place — has to re-key BEFORE the render mounts the destination, or the
|
|
4632
|
+
* destination misses its entry, builds a second grid, and the source tile's
|
|
4633
|
+
* deferred teardown destroys the first one along with everything typed into
|
|
4634
|
+
* it and not yet committed. Emitting after the render would lose that race
|
|
4635
|
+
* silently, which is the failure this repository keeps recording. The tree
|
|
4636
|
+
* is already correct at this point; only the DOM is stale.
|
|
4637
|
+
*
|
|
4638
|
+
* ══ WHAT THIS DELIBERATELY DOES NOT DO ══════════════════════════════
|
|
4639
|
+
*
|
|
4640
|
+
* A tab is not dragged OUT OF A FLOATING WINDOW's strip, and a tab dropped
|
|
4641
|
+
* on empty space does not become a window. Both are refused by omission
|
|
4642
|
+
* rather than half-built, and both have a reason. A window's tabs live in
|
|
4643
|
+
* `_windowToLeaf`'s record and not in the tree, so their source policy is
|
|
4644
|
+
* `_windowTabAction`'s and not this function's. And "dropped on nothing" in
|
|
4645
|
+
* HTML5 drag-and-drop is `dragend` with no `drop` — which is also exactly
|
|
4646
|
+
* what pressing Escape produces, so floating a window on it would float one
|
|
4647
|
+
* every time a user changed their mind. Crossing DESKTOPS is out for a
|
|
4648
|
+
* third reason: only the active desktop is rendered, so there is no target
|
|
4649
|
+
* to hit.
|
|
4650
|
+
*
|
|
4651
|
+
* @param {string} fromLeafId
|
|
4652
|
+
* @param {number} fromIdx
|
|
4653
|
+
* @param {{leafId: string, mode?: 'tab'|'fill'|'split', dir?: 'h'|'v',
|
|
4654
|
+
* before?: boolean, toIdx?: number}} target a `tabDropProbe`
|
|
4655
|
+
* answer, translated by the renderer
|
|
4656
|
+
* @returns {string|null} the leaf the tab landed in, or null if refused
|
|
4657
|
+
*/
|
|
4658
|
+
moveTabInto(fromLeafId, fromIdx, target = {}) {
|
|
4659
|
+
const tree = this._tree();
|
|
4660
|
+
const from = tree.get(fromLeafId);
|
|
4661
|
+
if (!from || from.kind !== "leaf") return null;
|
|
4662
|
+
const tabs = Array.isArray(from.tabs) ? from.tabs : [];
|
|
4663
|
+
if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
|
|
4664
|
+
let destId = target?.leafId || null;
|
|
4665
|
+
const dest = destId ? tree.get(destId) : null;
|
|
4666
|
+
if (!dest || dest.kind !== "leaf") return null;
|
|
4667
|
+
if (String(dest.content?.kind || "").startsWith("panel:")) return null;
|
|
4668
|
+
const mode = target.mode === "fill" || target.mode === "split" ? target.mode : "tab";
|
|
4669
|
+
if (destId === fromLeafId && mode !== "split") return null;
|
|
4670
|
+
if (destId === fromLeafId && tabs.length <= 1) return null;
|
|
4671
|
+
const src = tabs[fromIdx];
|
|
4672
|
+
const spec = {
|
|
4673
|
+
kind: src.kind,
|
|
4674
|
+
props: { ...src.props || {} },
|
|
4675
|
+
title: src.title || src.kind || ""
|
|
3483
4676
|
};
|
|
4677
|
+
if (mode === "split") {
|
|
4678
|
+
const newId = tree.split(destId, target.dir === "v" ? "v" : "h");
|
|
4679
|
+
if (!newId) return null;
|
|
4680
|
+
_halveInto(tree, destId, newId);
|
|
4681
|
+
if (target.before) _swapSiblings(tree, destId, newId);
|
|
4682
|
+
destId = newId;
|
|
4683
|
+
}
|
|
4684
|
+
if (mode === "tab") {
|
|
4685
|
+
const moved = tree.moveTabToLeaf(
|
|
4686
|
+
fromLeafId,
|
|
4687
|
+
fromIdx,
|
|
4688
|
+
destId,
|
|
4689
|
+
Number.isInteger(target.toIdx) ? target.toIdx : -1
|
|
4690
|
+
);
|
|
4691
|
+
if (!moved?.ok) return null;
|
|
4692
|
+
} else {
|
|
4693
|
+
tree.removeLeafTab(fromLeafId, fromIdx);
|
|
4694
|
+
tree.setLeafContent(destId, { kind: spec.kind, props: spec.props }, spec.title);
|
|
4695
|
+
}
|
|
4696
|
+
if (!(tree.get(fromLeafId)?.tabs || []).length) {
|
|
4697
|
+
this._seedHome(tree, fromLeafId);
|
|
4698
|
+
this._mergeStartTiles(tree, fromLeafId);
|
|
4699
|
+
}
|
|
4700
|
+
this._canonicalize(tree, this.desktops.active());
|
|
4701
|
+
try {
|
|
4702
|
+
this.eventBus?.emit?.(
|
|
4703
|
+
"wm:tab-moved",
|
|
4704
|
+
{ fromLeafId, toLeafId: destId, tab: spec, mode }
|
|
4705
|
+
);
|
|
4706
|
+
} catch (err) {
|
|
4707
|
+
console.warn("[wm] tab-moved emit failed", err);
|
|
4708
|
+
}
|
|
4709
|
+
if (tree.get(destId)) tree.focus(destId);
|
|
4710
|
+
this.renderer.render();
|
|
4711
|
+
this._persist();
|
|
4712
|
+
this._notifyChange("tab-moved");
|
|
4713
|
+
return destId;
|
|
4714
|
+
}
|
|
4715
|
+
/** The guards both float verbs share. A panel tile is chrome, not content;
|
|
4716
|
+
* a window placeholder is already a window; an empty tile has nothing to
|
|
4717
|
+
* carry — and, since C20 was extended, content that declared itself
|
|
4718
|
+
* unfloatable is not floated by ANY door.
|
|
4719
|
+
*
|
|
4720
|
+
* THE LAST ONE IS WHY THIS FUNCTION IS THE RIGHT PLACE. C20 let a content
|
|
4721
|
+
* factory return `chrome: { promote: false }`, and the renderer honoured
|
|
4722
|
+
* it by not PAINTING the float button. That is one door of four: the
|
|
4723
|
+
* tile's right-click menu has offered "Float this pane as a window" all
|
|
4724
|
+
* along (`shell.js`'s `_tileContextMenu`, whose only guard is
|
|
4725
|
+
* `isPanel || !leaf.content`), the chrome pull-down asks for `promote`,
|
|
4726
|
+
* and so now does the chrome's double-click. Each of them arrives here.
|
|
4727
|
+
*
|
|
4728
|
+
* The case it protects is an embedder's MASTER tile: the ground that
|
|
4729
|
+
* floating windows stand on. Floating it promotes the ground into a
|
|
4730
|
+
* window, which force-closes every window standing on it and re-seeds the
|
|
4731
|
+
* pane WITHOUT the props that made it a ground — reproduced end to end
|
|
4732
|
+
* before this guard existed. A veto the content states once should hold
|
|
4733
|
+
* for every gesture, not only the one the renderer draws. */
|
|
4734
|
+
_floatableLeaf(leafId) {
|
|
4735
|
+
const leaf = leafId ? this._tree().get(leafId) : null;
|
|
4736
|
+
if (!leaf || leaf.kind !== "leaf" || !leaf.content) return null;
|
|
4737
|
+
if (PANEL_KINDS.has(leaf.content.kind)) return null;
|
|
4738
|
+
if (leaf.content.kind === PLACEHOLDER_KIND) return null;
|
|
4739
|
+
if (this.renderer?.leafChrome?.(leaf.id)?.promote === false) return null;
|
|
4740
|
+
return leaf;
|
|
4741
|
+
}
|
|
4742
|
+
/**
|
|
4743
|
+
* The promote itself: build the window, mount the active tab in it, and
|
|
4744
|
+
* take the tabs out of the tree.
|
|
4745
|
+
*
|
|
4746
|
+
* @param {string} leafId the pane the tabs are coming out of
|
|
4747
|
+
* @param {object[]} tabs `{kind, props, title}`, in order
|
|
4748
|
+
* @param {number} activeTabIdx which of them the window shows first
|
|
4749
|
+
* @param {{wholePane: boolean, tabIdx?: number}} opts
|
|
4750
|
+
*/
|
|
4751
|
+
_promote(leafId, tabs, activeTabIdx, { wholePane, tabIdx = -1 }) {
|
|
4752
|
+
const tree = this._tree();
|
|
4753
|
+
const leaf = tree.get(leafId);
|
|
4754
|
+
const desktopIdx = this.desktops.activeIdx;
|
|
4755
|
+
const tabCount = Array.isArray(leaf.tabs) ? leaf.tabs.length : 1;
|
|
4756
|
+
const active = Math.max(0, Math.min(tabs.length - 1, activeTabIdx || 0));
|
|
4757
|
+
const original = { ...tabs[active], props: { ...tabs[active].props || {} } };
|
|
3484
4758
|
const contentEl = document.createElement("div");
|
|
3485
4759
|
contentEl.className = "twm-window-content";
|
|
3486
4760
|
contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
|
|
4761
|
+
const tabBarEl = document.createElement("div");
|
|
4762
|
+
tabBarEl.className = "twm-window-tabbar";
|
|
4763
|
+
const bodyEl = document.createElement("div");
|
|
4764
|
+
bodyEl.className = "twm-window-body";
|
|
4765
|
+
bodyEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0;";
|
|
4766
|
+
contentEl.append(tabBarEl, bodyEl);
|
|
3487
4767
|
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
3488
4768
|
const mountInfo = this.content.mount(
|
|
3489
4769
|
original.kind,
|
|
3490
|
-
|
|
4770
|
+
bodyEl,
|
|
3491
4771
|
original.props,
|
|
3492
4772
|
{ ...this.ctx, wm: this, windowId: winId }
|
|
3493
4773
|
);
|
|
@@ -3500,66 +4780,1207 @@ var WindowManager = class {
|
|
|
3500
4780
|
canMaximize: true,
|
|
3501
4781
|
canResize: true,
|
|
3502
4782
|
modal: false,
|
|
3503
|
-
|
|
4783
|
+
// C15. Dropping a promoted window on a tile PUTS IT BACK — as that
|
|
4784
|
+
// tile's content, as a split of it, or as one of its tabs. Off
|
|
4785
|
+
// unless the embedder asked, because it changes what a drag to an
|
|
4786
|
+
// edge means.
|
|
4787
|
+
snap: this.snapPromotion,
|
|
4788
|
+
snapController: this.snapPromotion ? this._snapController() : null,
|
|
4789
|
+
// R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
|
|
4790
|
+
// to one (C21) cannot be dragged a single pixel outside it, so
|
|
4791
|
+
// "drag a window from one tile to another" — the gesture all three
|
|
4792
|
+
// drop behaviours are built on — was not merely awkward, it was
|
|
4793
|
+
// invisible. For the length of a drag the window is re-parented
|
|
4794
|
+
// here, to the root every tile is inside; on release it goes back
|
|
4795
|
+
// into a pane, either the one it was dropped on or the one it came
|
|
4796
|
+
// from. Resolved per drag: the root outlives any tile, and a tile
|
|
4797
|
+
// grabbed once does not survive its own repaint.
|
|
4798
|
+
dragHost: () => this.rootEl,
|
|
4799
|
+
dragBounds: () => this._tileBounds(),
|
|
4800
|
+
// R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
|
|
4801
|
+
// tree; the useful thing to do with it is put it back, and filling
|
|
4802
|
+
// the screen with it is the one gesture that makes putting it back
|
|
4803
|
+
// harder. So the maximize button docks — and the separate demote
|
|
4804
|
+
// button the WM used to inject beside it is gone, because two
|
|
4805
|
+
// buttons for one verb is how you get a chrome nobody reads.
|
|
4806
|
+
onMaximize: () => this.bringBackWindow(winId),
|
|
4807
|
+
maximizeIcon: "close_fullscreen",
|
|
4808
|
+
maximizeTitle: "Back to tile",
|
|
4809
|
+
onClose: () => this._onManagedWindowClosed(winId, null)
|
|
3504
4810
|
});
|
|
3505
4811
|
this._windowToLeaf.set(winId, {
|
|
3506
|
-
//
|
|
3507
|
-
//
|
|
3508
|
-
//
|
|
4812
|
+
// The floated tabs now live in the window, not the tree. leafId
|
|
4813
|
+
// is null so "back to tile" re-docks into the desktop's primary
|
|
4814
|
+
// tile (see _onManagedWindowClosed) — the source tile itself
|
|
4815
|
+
// survives (re-seeded with HOME when it was emptied).
|
|
3509
4816
|
leafId: null,
|
|
3510
4817
|
desktopIdx,
|
|
3511
4818
|
original,
|
|
3512
4819
|
mountInfo,
|
|
3513
4820
|
window: win,
|
|
3514
4821
|
contentEl,
|
|
4822
|
+
bodyEl,
|
|
4823
|
+
tabBarEl,
|
|
4824
|
+
// R8. THE PANE'S TABS TRAVEL WITH IT, and this is where they live
|
|
4825
|
+
// while the window is open. `original` still mirrors the ACTIVE one
|
|
4826
|
+
// so every existing reader of the record — `openInWindow`, each of
|
|
4827
|
+
// `_onManagedWindowClosed`'s docks — keeps working unchanged; the
|
|
4828
|
+
// list beside it is what makes a dock restore ALL of them.
|
|
4829
|
+
tabs,
|
|
4830
|
+
activeTabIdx: active,
|
|
4831
|
+
strip: null,
|
|
4832
|
+
// ══ C21. WRITTEN HERE, BEFORE THE TREE IS TOUCHED ═══════════
|
|
4833
|
+
//
|
|
4834
|
+
// `homeLeafId` is the pane this window stands on, and it used to be
|
|
4835
|
+
// assigned at the BOTTOM of this function — after `_seedHome`,
|
|
4836
|
+
// after `_mergeStartTiles`, after the repaint. That ordering
|
|
4837
|
+
// destroyed a tile per promotion, and it looked like a window bug
|
|
4838
|
+
// because a window is what the user had just moved.
|
|
4839
|
+
//
|
|
4840
|
+
// `_mergeStartTiles` (below) refuses to merge a start tile that has
|
|
4841
|
+
// a window standing on it, and `_paneHoldsWindows` answers that
|
|
4842
|
+
// question two ways: THIS FIELD, and a DOM probe for a
|
|
4843
|
+
// `.twm-managed-window` inside the leaf. At the old assignment point
|
|
4844
|
+
// neither could be true yet — the field was unwritten and the window
|
|
4845
|
+
// had not been `moveTo`'d into the pane — so the pane that was one
|
|
4846
|
+
// line away from becoming this window's ground answered *nothing
|
|
4847
|
+
// floats here* and was merged into its neighbour.
|
|
4848
|
+
//
|
|
4849
|
+
// Three panes floated one after another ended as ONE pane: the
|
|
4850
|
+
// first promotion left a start tile, the second merged its own
|
|
4851
|
+
// freshly-seeded tile away, and so did the third. Every window
|
|
4852
|
+
// after the first was then left with a `homeLeafId` naming a leaf
|
|
4853
|
+
// the tree no longer had — so the `moveTo` below was skipped and the
|
|
4854
|
+
// window never became contained, `_rehomeContainedWindows` found no
|
|
4855
|
+
// element to re-home it into, and `bringBackWindow` fell through to
|
|
4856
|
+
// the PRIMARY tile and docked as a tab onto the ground ANOTHER
|
|
4857
|
+
// window was standing on. That is the whole of the reported
|
|
4858
|
+
// *"expand one to a tile > influences others or even tiles lost"*.
|
|
4859
|
+
//
|
|
4860
|
+
// Writing it here is the smallest fix that closes all of it: a
|
|
4861
|
+
// guard that already existed starts being able to see the window it
|
|
4862
|
+
// was written to protect. The `moveTo` stays at the bottom, because
|
|
4863
|
+
// it needs the wrap the repaint rebuilds.
|
|
4864
|
+
homeLeafId: this.promoteInPlace ? leafId : null,
|
|
3515
4865
|
// Set to true by bringBackWindow so the close path knows to
|
|
3516
4866
|
// restore the content instead of destroying it.
|
|
3517
4867
|
_demoting: false
|
|
3518
4868
|
});
|
|
3519
|
-
|
|
4869
|
+
this._syncWindowTabs(winId);
|
|
4870
|
+
if (!wholePane && tabCount > 1) tree.removeLeafTab(leafId, tabIdx);
|
|
4871
|
+
else this._seedHome(tree, leafId);
|
|
4872
|
+
this._mergeStartTiles(tree, leafId);
|
|
4873
|
+
tree.focus(leafId);
|
|
3520
4874
|
this._canonicalize(tree, this.desktops.active());
|
|
3521
4875
|
this.renderer.render();
|
|
4876
|
+
if (this.promoteInPlace) {
|
|
4877
|
+
const paneEl = this.renderer.leafEl(leafId);
|
|
4878
|
+
if (paneEl) win.moveTo(paneEl);
|
|
4879
|
+
}
|
|
3522
4880
|
win.show();
|
|
3523
4881
|
this._decorateManagedWindow(win, winId);
|
|
3524
4882
|
this._persist();
|
|
3525
4883
|
this._notifyChange("window-promoted");
|
|
4884
|
+
return winId;
|
|
3526
4885
|
}
|
|
3527
|
-
|
|
3528
|
-
|
|
4886
|
+
// ══ R8. The tab strip inside a floated pane ═══════════════════════
|
|
4887
|
+
/**
|
|
4888
|
+
* Draw (or hide) a window's tab strip, and keep its title honest.
|
|
4889
|
+
*
|
|
4890
|
+
* Hidden below two tabs, exactly as a pane's strip is
|
|
4891
|
+
* (`tile_renderer._renderTabBar`): the common case is one tab, and a strip
|
|
4892
|
+
* naming the one thing you are already looking at is a line of chrome
|
|
4893
|
+
* saying nothing. Building the strip lazily also means a window promoted
|
|
4894
|
+
* out of a single-tab pane costs no `NotebookTabBar` at all.
|
|
4895
|
+
*/
|
|
4896
|
+
_syncWindowTabs(winId) {
|
|
4897
|
+
const rec = this._windowToLeaf.get(winId);
|
|
4898
|
+
if (!rec) return;
|
|
4899
|
+
const tabs = rec.tabs || [];
|
|
4900
|
+
rec.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
|
|
4901
|
+
if (tabs.length <= 1) {
|
|
4902
|
+
try {
|
|
4903
|
+
rec.strip?.dispose();
|
|
4904
|
+
} catch {
|
|
4905
|
+
}
|
|
4906
|
+
rec.strip = null;
|
|
4907
|
+
rec.tabBarEl?.classList.add("twm-window-tabbar--hidden");
|
|
4908
|
+
return;
|
|
4909
|
+
}
|
|
4910
|
+
rec.tabBarEl?.classList.remove("twm-window-tabbar--hidden");
|
|
4911
|
+
if (!rec.strip) {
|
|
4912
|
+
rec.strip = createTabStrip({
|
|
4913
|
+
hostEl: rec.tabBarEl,
|
|
4914
|
+
taxonomy: this.taxonomy,
|
|
4915
|
+
onAction: (action, data) => this._windowTabAction(winId, action, data)
|
|
4916
|
+
});
|
|
4917
|
+
}
|
|
4918
|
+
rec.strip.update(tabs, rec.activeTabIdx);
|
|
4919
|
+
}
|
|
4920
|
+
/** The window strip's half of `_leafTabAction` — the same four verbs
|
|
4921
|
+
* against the window record instead of against the tree. */
|
|
4922
|
+
_windowTabAction(winId, action, data = {}) {
|
|
4923
|
+
const rec = this._windowToLeaf.get(winId);
|
|
4924
|
+
if (!rec) return;
|
|
4925
|
+
const tabs = rec.tabs || [];
|
|
4926
|
+
if (action === "switch") {
|
|
4927
|
+
this.showWindowTab(winId, data.idx);
|
|
4928
|
+
return;
|
|
4929
|
+
}
|
|
4930
|
+
if (action === "close") {
|
|
4931
|
+
if (data.idx < 0 || data.idx >= tabs.length) return;
|
|
4932
|
+
if (tabs.length <= 1) {
|
|
4933
|
+
try {
|
|
4934
|
+
rec.window.close({ force: true });
|
|
4935
|
+
} catch {
|
|
4936
|
+
}
|
|
4937
|
+
return;
|
|
4938
|
+
}
|
|
4939
|
+
tabs.splice(data.idx, 1);
|
|
4940
|
+
if (data.idx < rec.activeTabIdx) rec.activeTabIdx -= 1;
|
|
4941
|
+
else if (data.idx === rec.activeTabIdx) {
|
|
4942
|
+
rec.activeTabIdx = Math.max(0, data.idx - 1);
|
|
4943
|
+
this._mountWindowTab(winId);
|
|
4944
|
+
}
|
|
4945
|
+
this._syncWindowTabs(winId);
|
|
4946
|
+
this._notifyChange("window-tab-close");
|
|
4947
|
+
return;
|
|
4948
|
+
}
|
|
4949
|
+
if (action === "move") {
|
|
4950
|
+
const { from, to } = data;
|
|
4951
|
+
if (from == null || to == null) return;
|
|
4952
|
+
if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return;
|
|
4953
|
+
const moved = tabs.splice(from, 1)[0];
|
|
4954
|
+
tabs.splice(to, 0, moved);
|
|
4955
|
+
if (rec.activeTabIdx === from) rec.activeTabIdx = to;
|
|
4956
|
+
else if (from < rec.activeTabIdx && to >= rec.activeTabIdx) rec.activeTabIdx -= 1;
|
|
4957
|
+
else if (from > rec.activeTabIdx && to <= rec.activeTabIdx) rec.activeTabIdx += 1;
|
|
4958
|
+
this._syncWindowTabs(winId);
|
|
4959
|
+
return;
|
|
4960
|
+
}
|
|
4961
|
+
if (action === "menu") this._showWindowTabContextMenu(winId, data.idx, data.x, data.y);
|
|
4962
|
+
}
|
|
4963
|
+
/** Show one of a floated pane's tabs. Public because a window is the only
|
|
4964
|
+
* place this list exists — nothing else can reach it. */
|
|
4965
|
+
showWindowTab(winId, idx) {
|
|
4966
|
+
const rec = this._windowToLeaf.get(winId);
|
|
4967
|
+
if (!rec) return false;
|
|
4968
|
+
const tabs = rec.tabs || [];
|
|
4969
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return false;
|
|
4970
|
+
if (idx === rec.activeTabIdx) return true;
|
|
4971
|
+
rec.activeTabIdx = idx;
|
|
4972
|
+
this._mountWindowTab(winId);
|
|
4973
|
+
this._syncWindowTabs(winId);
|
|
4974
|
+
this._persist();
|
|
4975
|
+
this._notifyChange("window-tab-switch");
|
|
4976
|
+
return true;
|
|
4977
|
+
}
|
|
4978
|
+
/** Tear the current mount down and mount the active tab in its place.
|
|
4979
|
+
* `rec.original` follows, so a later dock puts back what is on screen. */
|
|
4980
|
+
_mountWindowTab(winId) {
|
|
4981
|
+
const rec = this._windowToLeaf.get(winId);
|
|
4982
|
+
if (!rec) return;
|
|
4983
|
+
const tab = (rec.tabs || [])[rec.activeTabIdx];
|
|
4984
|
+
if (!tab) return;
|
|
4985
|
+
try {
|
|
4986
|
+
rec.mountInfo?.destroy?.();
|
|
4987
|
+
} catch {
|
|
4988
|
+
}
|
|
4989
|
+
rec.bodyEl.innerHTML = "";
|
|
4990
|
+
rec.mountInfo = this.content.mount(
|
|
4991
|
+
tab.kind,
|
|
4992
|
+
rec.bodyEl,
|
|
4993
|
+
tab.props || {},
|
|
4994
|
+
{ ...this.ctx, wm: this, windowId: winId }
|
|
4995
|
+
);
|
|
4996
|
+
rec.original = {
|
|
4997
|
+
kind: tab.kind,
|
|
4998
|
+
props: { ...tab.props || {} },
|
|
4999
|
+
title: rec.mountInfo?.title || tab.title || tab.kind
|
|
5000
|
+
};
|
|
5001
|
+
this._setWindowTitle(rec, rec.original.title);
|
|
5002
|
+
}
|
|
5003
|
+
/** The window's title, in both places it is kept. */
|
|
5004
|
+
_setWindowTitle(rec, title) {
|
|
5005
|
+
try {
|
|
5006
|
+
const titleEl = rec.window?.element?.querySelector(".twm-managed-window__title");
|
|
5007
|
+
if (titleEl) titleEl.textContent = title;
|
|
5008
|
+
if (rec.window) rec.window.title = title;
|
|
5009
|
+
} catch {
|
|
5010
|
+
}
|
|
5011
|
+
}
|
|
5012
|
+
/** The window strip's context menu. Deliberately the close verbs and
|
|
5013
|
+
* nothing else: a tab in a window is already out of the tree, so
|
|
5014
|
+
* "open in a window" — the verb R9 adds to a PANE's tab menu — has
|
|
5015
|
+
* nowhere further to go. */
|
|
5016
|
+
_showWindowTabContextMenu(winId, idx, x, y) {
|
|
5017
|
+
const rec = this._windowToLeaf.get(winId);
|
|
5018
|
+
const tabs = rec?.tabs || [];
|
|
5019
|
+
if (!tabs.length) return;
|
|
5020
|
+
const items = [{ label: "Close tab", icon: "close", action: "close" }];
|
|
5021
|
+
if (tabs.length > 1) {
|
|
5022
|
+
items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
|
|
5023
|
+
}
|
|
5024
|
+
showContextMenu(x, y, items, (action) => {
|
|
5025
|
+
const live = this._windowToLeaf.get(winId);
|
|
5026
|
+
if (!live) return;
|
|
5027
|
+
if (action === "close") this._windowTabAction(winId, "close", { idx });
|
|
5028
|
+
else if (action === "close-others") {
|
|
5029
|
+
const keep = live.tabs[idx];
|
|
5030
|
+
if (!keep) return;
|
|
5031
|
+
const remount = idx !== live.activeTabIdx;
|
|
5032
|
+
live.tabs = [keep];
|
|
5033
|
+
live.activeTabIdx = 0;
|
|
5034
|
+
if (remount) this._mountWindowTab(winId);
|
|
5035
|
+
this._syncWindowTabs(winId);
|
|
5036
|
+
this._notifyChange("window-tab-close-others");
|
|
5037
|
+
}
|
|
5038
|
+
});
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Re-parent every pane-contained window into its pane's CURRENT wrap.
|
|
5042
|
+
*
|
|
5043
|
+
* Called after each repaint. A leaf's wrap is cached per (kind, props, tab
|
|
5044
|
+
* fingerprint) and rebuilt when any of those change, so a window parented
|
|
5045
|
+
* into it is thrown away with the old wrap — silently, because nothing
|
|
5046
|
+
* throws and the window object is still perfectly alive.
|
|
5047
|
+
*
|
|
5048
|
+
* `moveTo` returns false when the container has not changed, so this is a
|
|
5049
|
+
* no-op on every repaint that did not rebuild the pane in question.
|
|
5050
|
+
*/
|
|
5051
|
+
_rehomeContainedWindows() {
|
|
5052
|
+
if (!this.promoteInPlace) return;
|
|
5053
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
5054
|
+
if (!rec.homeLeafId || !rec.window) continue;
|
|
5055
|
+
if (rec.window.dragOrigin) continue;
|
|
5056
|
+
if (rec.window.isMaximized && rec.window.container === this.rootEl) continue;
|
|
5057
|
+
if (rec.desktopIdx !== this.desktops.activeIdx) {
|
|
5058
|
+
if (!rec.homeContainer && rec.window.element?.isConnected) {
|
|
5059
|
+
try {
|
|
5060
|
+
rec.window.element.remove();
|
|
5061
|
+
} catch {
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
continue;
|
|
5065
|
+
}
|
|
5066
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
5067
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
5068
|
+
if (tree && !tree.get(rec.homeLeafId)) {
|
|
5069
|
+
let survivor = tree.primaryLeafId();
|
|
5070
|
+
if (!survivor) {
|
|
5071
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
5072
|
+
if (spawned) {
|
|
5073
|
+
this._seedHome(tree, spawned);
|
|
5074
|
+
this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
5075
|
+
survivor = tree.primaryLeafId();
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
if (survivor) rec.homeLeafId = survivor;
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5081
|
+
const paneEl = rec.homeContainer ? rec.homeContainer() || null : this.renderer.leafEl(rec.homeLeafId);
|
|
5082
|
+
if (!paneEl) continue;
|
|
5083
|
+
if (paneEl === rec.window.container) {
|
|
5084
|
+
const el = rec.window.element;
|
|
5085
|
+
if (el && !el.isConnected) {
|
|
5086
|
+
try {
|
|
5087
|
+
paneEl.appendChild(el);
|
|
5088
|
+
} catch (err) {
|
|
5089
|
+
console.warn("[wm] re-attach failed", err);
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
continue;
|
|
5093
|
+
}
|
|
5094
|
+
try {
|
|
5095
|
+
rec.window.moveTo(paneEl);
|
|
5096
|
+
} catch (err) {
|
|
5097
|
+
console.warn("[wm] re-home failed", err);
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
}
|
|
5101
|
+
/**
|
|
5102
|
+
* C21, as a verb a consumer can call: put THIS window back where it belongs
|
|
5103
|
+
* and say whether it moved.
|
|
5104
|
+
*
|
|
5105
|
+
* The taskbar needs it. A minimised window's element may be out of the
|
|
5106
|
+
* document — its desktop is not on screen, or its pane was closed — and
|
|
5107
|
+
* un-minimising it in that state clears `isMinimized` (so its button
|
|
5108
|
+
* disappears, the last handle on it) while showing nothing. `restore` has
|
|
5109
|
+
* to be able to repair the window BEFORE it makes it visible, and
|
|
5110
|
+
* `_rehomeContainedWindows` is the thing that knows how; it was simply not
|
|
5111
|
+
* reachable, and `taskbar.js`'s own docstring asserted it ran for these
|
|
5112
|
+
* windows when the guard above meant it did not.
|
|
5113
|
+
*
|
|
5114
|
+
* IT SWITCHES DESKTOPS WHEN IT HAS TO, and that is the half a bare re-home
|
|
5115
|
+
* cannot do. A window belongs to one desktop; if that desktop is not on
|
|
5116
|
+
* screen, the honest answer to *show me this window* is the one every
|
|
5117
|
+
* taskbar in every window manager gives — go to where it lives. Restoring
|
|
5118
|
+
* it onto the page the user happens to be looking at would move a window
|
|
5119
|
+
* between pages as a side effect of asking to see it, and that is a tile
|
|
5120
|
+
* decision being made by a window verb.
|
|
5121
|
+
*
|
|
5122
|
+
* @param {object} win a live ManagedWindow
|
|
5123
|
+
* @returns {boolean} whether this WM owns it (and so has revealed it)
|
|
5124
|
+
*/
|
|
5125
|
+
revealWindow(win) {
|
|
5126
|
+
if (!win) return false;
|
|
5127
|
+
let rec = null;
|
|
5128
|
+
for (const [, r] of this._windowToLeaf) {
|
|
5129
|
+
if (r.window === win) {
|
|
5130
|
+
rec = r;
|
|
5131
|
+
break;
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
if (!rec) return false;
|
|
5135
|
+
if (!rec.homeLeafId && !rec.homeContainer) return false;
|
|
5136
|
+
if (rec.desktopIdx !== this.desktops.activeIdx && this.desktops.desktops[rec.desktopIdx]) {
|
|
5137
|
+
this.switchDesktop(rec.desktopIdx);
|
|
5138
|
+
} else {
|
|
5139
|
+
this._rehomeContainedWindows();
|
|
5140
|
+
}
|
|
5141
|
+
return true;
|
|
5142
|
+
}
|
|
5143
|
+
/**
|
|
5144
|
+
* R12. The rectangle an ESCAPED window may occupy — the tiles, and not the
|
|
5145
|
+
* panels — in the root's own coordinates.
|
|
5146
|
+
*
|
|
5147
|
+
* R1 let a window leave its pane so it could reach another one, and the
|
|
5148
|
+
* cheapest box to let it leave into is the root every tile shares. But the
|
|
5149
|
+
* root holds the docked panels too, so the bottom edge stopped being an
|
|
5150
|
+
* edge: a window could be dragged down over the bottom panel and dropped
|
|
5151
|
+
* there, half-covering a surface that has its own scroll and its own
|
|
5152
|
+
* chrome, with no way to tell it had happened except that it looked wrong.
|
|
5153
|
+
*
|
|
5154
|
+
* The answer is the UNION OF THE CONTENT LEAVES rather than "the root minus
|
|
5155
|
+
* the panel I know about": panels dock left, right and bottom, an embedder
|
|
5156
|
+
* may show any combination of them, and each one may be collapsed. A union
|
|
5157
|
+
* of the tiles is right for all of those without enumerating any of them,
|
|
5158
|
+
* and it degrades to the root when a desktop is somehow all panel.
|
|
5159
|
+
*/
|
|
5160
|
+
_tileBounds() {
|
|
5161
|
+
const root = this.rootEl;
|
|
5162
|
+
if (!root) return null;
|
|
5163
|
+
const layer = this._layerRect();
|
|
5164
|
+
if (!layer) return null;
|
|
5165
|
+
const rootRect = root.getBoundingClientRect();
|
|
5166
|
+
const ox = rootRect.left + root.clientLeft - root.scrollLeft;
|
|
5167
|
+
const oy = rootRect.top + root.clientTop - root.scrollTop;
|
|
5168
|
+
return {
|
|
5169
|
+
minX: layer.left - ox,
|
|
5170
|
+
minY: layer.top - oy,
|
|
5171
|
+
width: layer.width,
|
|
5172
|
+
height: layer.height
|
|
5173
|
+
};
|
|
5174
|
+
}
|
|
5175
|
+
/**
|
|
5176
|
+
* R13. THE LAYER, in the VIEWPORT pixels a hit-test speaks — the union of
|
|
5177
|
+
* the content leaves, before it is converted into anybody's coordinates.
|
|
5178
|
+
*
|
|
5179
|
+
* This is `_tileBounds` with the last step taken off, and it stays a
|
|
5180
|
+
* separate function rather than being folded back into it because the two
|
|
5181
|
+
* frames have different readers: `_tileBounds` answers `dragBounds`, which
|
|
5182
|
+
* `ManagedWindow._bounds()` uses to clamp a window in the ROOT's
|
|
5183
|
+
* coordinates, and this answers anything measuring against the page.
|
|
5184
|
+
*
|
|
5185
|
+
* R14 REMOVED ITS OTHER READER. R13's maximise preview was drawn from here
|
|
5186
|
+
* so that it would be the same measurement `toggleMaximize` would deliver
|
|
5187
|
+
* through `dragBounds` — C15's rule, THE PREVIEW MAY NOT PROMISE A
|
|
5188
|
+
* RECTANGLE THE DROP DOES NOT DELIVER, applied to the one mode that did not
|
|
5189
|
+
* dock. There is no such mode now: the top edge docks like every other
|
|
5190
|
+
* zone, its preview is the TILE (`_homeDockTarget`), and the layer's only
|
|
5191
|
+
* remaining job is the clamp. Kept as its own function because the clamp
|
|
5192
|
+
* still needs the union of the CONTENT leaves rather than the root, which
|
|
5193
|
+
* is a definition, not a call site.
|
|
5194
|
+
*
|
|
5195
|
+
* The union of the CONTENT LEAVES rather than the root, for the reason
|
|
5196
|
+
* `_tileBounds` gives at length: the root holds the docked panels too.
|
|
5197
|
+
*
|
|
5198
|
+
* Null when nothing has a box yet — a layout that has not happened, a
|
|
5199
|
+
* desktop whose tiles are all zero-sized. Every caller treats that as "do
|
|
5200
|
+
* not promise anything", which is the only honest answer available.
|
|
5201
|
+
*/
|
|
5202
|
+
_layerRect() {
|
|
5203
|
+
let l = Infinity, tp = Infinity, r = -Infinity, b = -Infinity;
|
|
5204
|
+
for (const leaf of this._tree().leaves()) {
|
|
5205
|
+
if (PANEL_KINDS.has(leaf.content?.kind)) continue;
|
|
5206
|
+
const el = this.renderer.leafEl(leaf.id);
|
|
5207
|
+
if (!el) continue;
|
|
5208
|
+
const box = el.getBoundingClientRect();
|
|
5209
|
+
if (!box.width || !box.height) continue;
|
|
5210
|
+
l = Math.min(l, box.left);
|
|
5211
|
+
tp = Math.min(tp, box.top);
|
|
5212
|
+
r = Math.max(r, box.right);
|
|
5213
|
+
b = Math.max(b, box.bottom);
|
|
5214
|
+
}
|
|
5215
|
+
if (!Number.isFinite(l)) return null;
|
|
5216
|
+
return { left: l, top: tp, width: r - l, height: b - tp };
|
|
5217
|
+
}
|
|
5218
|
+
/** Dock the window's content back into the tile it came from — or, when it
|
|
5219
|
+
* came from none, into the desktop's primary tile — then close the window.
|
|
5220
|
+
*
|
|
5221
|
+
* R7. This is what the MAXIMIZE button now does, so it is reached far more
|
|
5222
|
+
* often than it was as a button of its own, and "somewhere other than where
|
|
5223
|
+
* the window came from" stopped being a defensible answer. Promoting a pane
|
|
5224
|
+
* re-seeds it with the root kind — ground for the window to stand on — so
|
|
5225
|
+
* FILLING that pane is the exact inverse: the content goes back where it
|
|
5226
|
+
* was lifted from, replacing the ground it has been standing on.
|
|
5227
|
+
*
|
|
5228
|
+
* A home pane that has since acquired content is a different story. The
|
|
5229
|
+
* user opened something there, and replacing it would destroy work the
|
|
5230
|
+
* window knows nothing about, so the content joins it as a tab instead.
|
|
5231
|
+
* With no home pane at all — an Alt+N window, or any window under an
|
|
5232
|
+
* embedder that does not confine promotions — this is the primary-tile tab
|
|
5233
|
+
* it has always been. */
|
|
3529
5234
|
bringBackWindow(windowId) {
|
|
3530
5235
|
const rec = this._windowToLeaf.get(windowId);
|
|
3531
|
-
if (!rec) return;
|
|
5236
|
+
if (!rec) return false;
|
|
5237
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
5238
|
+
const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
|
|
5239
|
+
if (home && home.kind === "leaf") {
|
|
5240
|
+
rec._dock = {
|
|
5241
|
+
leafId: rec.homeLeafId,
|
|
5242
|
+
mode: this._isStartTile(home) ? "fill" : "tab"
|
|
5243
|
+
};
|
|
5244
|
+
}
|
|
3532
5245
|
rec._demoting = true;
|
|
3533
5246
|
try {
|
|
3534
5247
|
rec.window.close({ force: true });
|
|
3535
5248
|
} catch (err) {
|
|
3536
5249
|
console.warn("[wm] bringBack: close failed", err);
|
|
3537
5250
|
}
|
|
5251
|
+
return true;
|
|
5252
|
+
}
|
|
5253
|
+
/**
|
|
5254
|
+
* R11. ADOPT A WINDOW THE EMBEDDER BUILT ITSELF.
|
|
5255
|
+
*
|
|
5256
|
+
* Everything R1–R10 gave a window — escaping its pane for the length of a
|
|
5257
|
+
* drag, going half-transparent once it is outside, the edge/body/ground
|
|
5258
|
+
* drops, maximise meaning *back to tile* — is wired in `_promote`, and so
|
|
5259
|
+
* belongs only to windows this WM lifted out of the tree. An embedder that
|
|
5260
|
+
* stands its own `ManagedWindow` on a pane (`snap: true` against the pane's
|
|
5261
|
+
* ground) got none of it: `_snapCommit` resolves the window through
|
|
5262
|
+
* `_windowToLeaf` and returns false for one it never built, so every drop
|
|
5263
|
+
* previewed correctly and then quietly did nothing.
|
|
5264
|
+
*
|
|
5265
|
+
* The fix is not to make the WM build those windows — the embedder has its
|
|
5266
|
+
* own reasons for the ones it builds, and taking that over would mean
|
|
5267
|
+
* taking over their content, their identity and their lifetime. It is to
|
|
5268
|
+
* let a window JOIN the tree's world after the fact, which needs exactly
|
|
5269
|
+
* two things: the drag options set on the component, and a record saying
|
|
5270
|
+
* what content to restore when the window is docked.
|
|
5271
|
+
*
|
|
5272
|
+
* CALL THIS BEFORE `show()`. `maximizeIcon` is read when the chrome is
|
|
5273
|
+
* built (`managed_window.js:703`) and the chrome is built lazily by `show`
|
|
5274
|
+
* (`:281`), so a window adopted afterwards would carry the right behaviour
|
|
5275
|
+
* behind a button still drawing a square.
|
|
5276
|
+
*
|
|
5277
|
+
* TEARDOWN STAYS THE EMBEDDER'S. `mountInfo` is optional and normally
|
|
5278
|
+
* omitted: a window that already destroys its own content in its `onClose`
|
|
5279
|
+
* would otherwise destroy it twice, once here and once there. The
|
|
5280
|
+
* embedder's handler is chained, not replaced, and runs after this one — so
|
|
5281
|
+
* a dock has already re-mounted the content into the tile by the time the
|
|
5282
|
+
* window's own teardown disposes of the copy that was floating.
|
|
5283
|
+
*
|
|
5284
|
+
* @param {object} win a live ManagedWindow, not yet shown
|
|
5285
|
+
* @param {object} spec
|
|
5286
|
+
* @param {string} spec.kind content kind to restore into a tile
|
|
5287
|
+
* @param {object} [spec.props] its props
|
|
5288
|
+
* @param {string} [spec.title] the tab title after a dock
|
|
5289
|
+
* @param {string} [spec.homeLeafId] the pane it stands on: what "back to
|
|
5290
|
+
* tile" targets, and what the probe stays silent inside
|
|
5291
|
+
* @param {function} [spec.homeContainer] `() => HTMLElement` — the box
|
|
5292
|
+
* WITHIN that pane the window is contained to. A canvas pane's
|
|
5293
|
+
* ground is not the leaf wrap, and re-homing to the wrap after a
|
|
5294
|
+
* repaint would lift the window out of the ground it belongs to.
|
|
5295
|
+
* @param {object} [spec.mountInfo] `{destroy}`, if teardown is ours
|
|
5296
|
+
* @returns {string|null} the window id, or null if it could not be adopted
|
|
5297
|
+
*/
|
|
5298
|
+
adoptWindow(win, spec = {}) {
|
|
5299
|
+
const winId = win?.id;
|
|
5300
|
+
if (!winId || !spec.kind) return null;
|
|
5301
|
+
if (this._windowToLeaf.has(winId)) return winId;
|
|
5302
|
+
if (this.snapPromotion) {
|
|
5303
|
+
win.snap = win.snap && win.canDrag && win.canResize;
|
|
5304
|
+
win.snapController = this._snapController();
|
|
5305
|
+
}
|
|
5306
|
+
win.dragHost = () => this.rootEl;
|
|
5307
|
+
win.dragBounds = () => this._tileBounds();
|
|
5308
|
+
win.onMaximize = () => this.bringBackWindow(winId);
|
|
5309
|
+
win.maximizeIcon = "close_fullscreen";
|
|
5310
|
+
win.maximizeTitle = "Back to tile";
|
|
5311
|
+
const original = {
|
|
5312
|
+
kind: spec.kind,
|
|
5313
|
+
props: spec.props || {},
|
|
5314
|
+
title: spec.title || spec.kind
|
|
5315
|
+
};
|
|
5316
|
+
this._windowToLeaf.set(winId, {
|
|
5317
|
+
leafId: null,
|
|
5318
|
+
desktopIdx: this.desktops.activeIdx,
|
|
5319
|
+
original,
|
|
5320
|
+
mountInfo: spec.mountInfo || null,
|
|
5321
|
+
window: win,
|
|
5322
|
+
contentEl: null,
|
|
5323
|
+
bodyEl: null,
|
|
5324
|
+
tabBarEl: null,
|
|
5325
|
+
tabs: [original],
|
|
5326
|
+
activeTabIdx: 0,
|
|
5327
|
+
strip: null,
|
|
5328
|
+
homeLeafId: spec.homeLeafId || null,
|
|
5329
|
+
homeContainer: spec.homeContainer || null,
|
|
5330
|
+
adopted: true,
|
|
5331
|
+
_demoting: false
|
|
5332
|
+
});
|
|
5333
|
+
const prior = win.onClose;
|
|
5334
|
+
win.onClose = () => {
|
|
5335
|
+
this._onManagedWindowClosed(winId, null);
|
|
5336
|
+
prior?.();
|
|
5337
|
+
};
|
|
5338
|
+
return winId;
|
|
5339
|
+
}
|
|
5340
|
+
// ══ C15. Snap-to-promote ══════════════════════════════════════════
|
|
5341
|
+
/**
|
|
5342
|
+
* The snap controller a promoted window is given. It answers the two
|
|
5343
|
+
* questions ManagedWindow's own C11 snap cannot, because both are about a
|
|
5344
|
+
* tree it does not know exists:
|
|
5345
|
+
*
|
|
5346
|
+
* probe which TILE is under the pointer, and — since R15 — which edge
|
|
5347
|
+
* of it THE DRAGGED WINDOW'S OWN BORDERS have reached, and what
|
|
5348
|
+
* would dropping there actually produce: a half of that tile, a
|
|
5349
|
+
* quarter of the layer, the tile entire, or (R13, at the top edge
|
|
5350
|
+
* of the pane the window already stands on) the whole layer,
|
|
5351
|
+
* which is the one answer that is not a dock at all. The preview
|
|
5352
|
+
* draws exactly that rectangle, because a preview that promises a
|
|
5353
|
+
* half and delivers a quarter is worse than no preview.
|
|
5354
|
+
* commit put the window in the tree — or, for R13's maximise, leave it
|
|
5355
|
+
* floating and give it the layer. Over an EMPTY tile the dock is
|
|
5356
|
+
* unambiguous and happens on release. Over an OCCUPIED tile the
|
|
5357
|
+
* edges are unambiguous too — the drag chose a side, so the
|
|
5358
|
+
* side is the split — and only the CENTRE was ever genuinely a
|
|
5359
|
+
* question, which is why it is the zone that changed most.
|
|
5360
|
+
*
|
|
5361
|
+
* Built once and reused: the probe runs per pointermove and allocating a
|
|
5362
|
+
* closure per window per drag is free, but the memo keeps the identity
|
|
5363
|
+
* stable for anyone comparing controllers.
|
|
5364
|
+
*/
|
|
5365
|
+
_snapController() {
|
|
5366
|
+
if (this._snapCtl) return this._snapCtl;
|
|
5367
|
+
this._snapCtl = {
|
|
5368
|
+
probe: (e, win) => this._snapProbe(e, win),
|
|
5369
|
+
commit: (probe, win) => this._snapCommit(probe, win)
|
|
5370
|
+
};
|
|
5371
|
+
return this._snapCtl;
|
|
5372
|
+
}
|
|
5373
|
+
/**
|
|
5374
|
+
* How close to a tile's edge the DRAGGED WINDOW'S matching edge must come
|
|
5375
|
+
* for a dock to arm — in PIXELS, and a narrow band. Since R15 it is also
|
|
5376
|
+
* the minimum distance the drag must have travelled toward that edge
|
|
5377
|
+
* inside the window's own pane; `_snapSide` argues both, and this is the
|
|
5378
|
+
* one constant either of them is measured in.
|
|
5379
|
+
*
|
|
5380
|
+
* This was a third of the tile, measured as a fraction, with the remaining
|
|
5381
|
+
* middle ninth treated as a fourth zone that offered a three-way choice.
|
|
5382
|
+
* Both halves of that were wrong, and together they made docking the
|
|
5383
|
+
* DEFAULT rather than a deliberate gesture:
|
|
5384
|
+
*
|
|
5385
|
+
* - A fraction means the band grows with the tile. On a maximised layer
|
|
5386
|
+
* a "third" is several hundred pixels, so a window could not be moved
|
|
5387
|
+
* anywhere near the left half of the screen without arming a split.
|
|
5388
|
+
* - The centre zone armed over the whole middle of every tile and
|
|
5389
|
+
* previewed the ENTIRE tile, so simply picking a window up and moving
|
|
5390
|
+
* it a few pixels lit the whole pane. Every move looked like a dock
|
|
5391
|
+
* because every move WAS one.
|
|
5392
|
+
*
|
|
5393
|
+
* Aero snap is an edge gesture: you push THE WINDOW at an edge — which is
|
|
5394
|
+
* what R15 finally made it measure. So the band is a fixed 28px from the
|
|
5395
|
+
* edge, and what lies past it is decided by the
|
|
5396
|
+
* pane rather than by the pointer: in the window's OWN pane the centre
|
|
5397
|
+
* arms nothing at all and the drop is simply a window that moved (R2), and
|
|
5398
|
+
* in any other pane it is the non-destructive tab or fill of R5/R6. The
|
|
5399
|
+
* band itself never grows with the tile, which is the whole of the fix.
|
|
5400
|
+
* Docking a whole tile is also still available without any drag at all —
|
|
5401
|
+
* the "back to tile" button in the window's own chrome, which names the
|
|
5402
|
+
* destination instead of guessing it.
|
|
5403
|
+
*/
|
|
5404
|
+
static get SNAP_EDGE_PX() {
|
|
5405
|
+
return 28;
|
|
5406
|
+
}
|
|
5407
|
+
_snapProbe(e, win) {
|
|
5408
|
+
const leafEl = this._leafElAt(e.clientX, e.clientY, win);
|
|
5409
|
+
if (!leafEl) return null;
|
|
5410
|
+
const own = !!(win?.dragOrigin && leafEl.contains(win.dragOrigin));
|
|
5411
|
+
const leafId = leafEl.dataset.leafId;
|
|
5412
|
+
const desktopIdx = this.desktops.activeIdx;
|
|
5413
|
+
const leaf = this._tree().get(leafId);
|
|
5414
|
+
if (!leaf || leaf.kind !== "leaf") return null;
|
|
5415
|
+
if (String(leaf.content?.kind || "").startsWith("panel:")) return null;
|
|
5416
|
+
const r = leafEl.getBoundingClientRect();
|
|
5417
|
+
const side = leaf.content ? this._snapSide(r, e, win, own) : null;
|
|
5418
|
+
if (own && side === "top") {
|
|
5419
|
+
const home = this._homeDockTarget(win);
|
|
5420
|
+
if (!home) return null;
|
|
5421
|
+
return {
|
|
5422
|
+
key: `${home.leafId}:home`,
|
|
5423
|
+
rect: home.rect,
|
|
5424
|
+
leafId: home.leafId,
|
|
5425
|
+
desktopIdx,
|
|
5426
|
+
side,
|
|
5427
|
+
mode: "home",
|
|
5428
|
+
leafRect: r
|
|
5429
|
+
};
|
|
5430
|
+
}
|
|
5431
|
+
return this._dropZoneFor({ leafId, leaf, r, side, own, desktopIdx });
|
|
5432
|
+
}
|
|
5433
|
+
/**
|
|
5434
|
+
* R17 (C33). THE ZONE MATRIX'S TAIL — SPLIT / NOTHING / TAB / FILL — SHARED
|
|
5435
|
+
* BY THE TWO THINGS THAT CAN BE DROPPED ON A TILE.
|
|
5436
|
+
*
|
|
5437
|
+
* A dragged WINDOW and a dragged TAB ask the same question of a pane: given
|
|
5438
|
+
* that the pointer is in this leaf and the edge test answered `side`, what
|
|
5439
|
+
* would releasing here produce? Every answer below was written for the
|
|
5440
|
+
* window drop and every one of them is right for a tab, so this is an
|
|
5441
|
+
* extraction and not a generalisation — `_snapProbe` keeps everything ABOVE
|
|
5442
|
+
* it unchanged, including R14's `own && side === 'top'` home branch, which
|
|
5443
|
+
* is a window's alone (a tab has no window to bring back) and therefore
|
|
5444
|
+
* stays where it was, between the side computation and this call.
|
|
5445
|
+
*
|
|
5446
|
+
* The alternative was a second copy in `tabDropProbe`, and a second copy of
|
|
5447
|
+
* a matrix the product owner has already revised four times (R2, R4, R5/R6,
|
|
5448
|
+
* R14) is a guarantee that the two gestures will one day disagree about
|
|
5449
|
+
* what the centre of a start tile means. `web/js/shell/snap_zones.test.mjs`
|
|
5450
|
+
* in the Tables consumer asserts every cell of the window matrix and is the
|
|
5451
|
+
* regression gate on this extraction: byte-identical window behaviour is
|
|
5452
|
+
* the whole of its back-compatibility claim.
|
|
5453
|
+
*/
|
|
5454
|
+
_dropZoneFor({ leafId, leaf, r, side, own, desktopIdx }) {
|
|
5455
|
+
if (side) {
|
|
5456
|
+
return {
|
|
5457
|
+
key: `${leafId}:${side}`,
|
|
5458
|
+
rect: _halfOf(r, side),
|
|
5459
|
+
leafId,
|
|
5460
|
+
desktopIdx,
|
|
5461
|
+
side,
|
|
5462
|
+
mode: "split",
|
|
5463
|
+
leafRect: r
|
|
5464
|
+
};
|
|
5465
|
+
}
|
|
5466
|
+
if (own) return null;
|
|
5467
|
+
const fills = !leaf.content || this._isStartTile(leaf);
|
|
5468
|
+
return {
|
|
5469
|
+
key: `${leafId}:${fills ? "fill" : "tab"}`,
|
|
5470
|
+
rect: _halfOf(r, null),
|
|
5471
|
+
leafId,
|
|
5472
|
+
desktopIdx,
|
|
5473
|
+
side: null,
|
|
5474
|
+
mode: fills ? "fill" : "tab",
|
|
5475
|
+
leafRect: r
|
|
5476
|
+
};
|
|
5477
|
+
}
|
|
5478
|
+
/**
|
|
5479
|
+
* R18 (C33). THE SAME PROBE, FOR A DRAGGED TAB — PUBLIC, because the
|
|
5480
|
+
* renderer is what holds the drag and the renderer is not the WM.
|
|
5481
|
+
*
|
|
5482
|
+
* ══ WHY THIS IS NOT `_snapProbe(e, null)` ═══════════════════════════
|
|
5483
|
+
*
|
|
5484
|
+
* It very nearly is, and the geometry underneath is literally the same
|
|
5485
|
+
* code: `_snapSide(r, e, null, false)` falls to the POINTER-distance branch
|
|
5486
|
+
* by construction — `_draggedRect(null)` is null, so `dist` takes the
|
|
5487
|
+
* `e.clientX/Y` arm and `along` scores every edge zero. A tab has no
|
|
5488
|
+
* rectangle being dragged and no `_dragState`, and that is not a gap to
|
|
5489
|
+
* paper over: the pointer IS the whole gesture for a tab, which is exactly
|
|
5490
|
+
* the pre-R15 model that `_snapSide`'s fallback preserves.
|
|
5491
|
+
*
|
|
5492
|
+
* Two things differ, and neither could be expressed by passing a null
|
|
5493
|
+
* window to `_snapProbe`:
|
|
5494
|
+
*
|
|
5495
|
+
* R14's HOME BRANCH IS A WINDOW'S. `own && side === 'top'` means "put the
|
|
5496
|
+
* window back in its tile", and a tab is already in a tile. Reaching that
|
|
5497
|
+
* branch with `win === null` would ask `_homeDockTarget(null)`, which
|
|
5498
|
+
* answers null, so the top edge of the source pane would fall silent
|
|
5499
|
+
* rather than split — a hole in the matrix produced by inheritance.
|
|
5500
|
+
*
|
|
5501
|
+
* A SINGLE-TAB SOURCE PANE ARMS NOTHING, ANYWHERE. `_dropZoneFor` already
|
|
5502
|
+
* silences the source pane's CENTRE; its edges are useful for a pane with
|
|
5503
|
+
* siblings ("tear this tab off into a split beside the others") and are a
|
|
5504
|
+
* wash for a pane with one tab, where the outcome is the pane's only
|
|
5505
|
+
* content in one half and a freshly seeded ground in the other. That is a
|
|
5506
|
+
* preview promising something no one wants, so it is refused BEFORE the
|
|
5507
|
+
* preview is drawn rather than at the drop — C15's rule is that the
|
|
5508
|
+
* rectangle drawn is the one released, and the honest way to keep it is
|
|
5509
|
+
* never to draw one.
|
|
5510
|
+
*
|
|
5511
|
+
* `own: false` is passed to `_snapSide` deliberately. Its `own` parameter
|
|
5512
|
+
* gates R2's direction guard, which measures a WINDOW's travel out of
|
|
5513
|
+
* `_dragState`; a tab drag has none, so `guarded` would be false anyway and
|
|
5514
|
+
* passing `true` would only obscure that. `own` still governs the centre,
|
|
5515
|
+
* which is why it goes to `_dropZoneFor` and not to `_snapSide`.
|
|
5516
|
+
*
|
|
5517
|
+
* @param {{clientX: number, clientY: number}} e the pointer, mid-drag
|
|
5518
|
+
* @param {{sourceLeafId?: string}} [opts] the leaf the tab left
|
|
5519
|
+
* @returns {object|null} the same probe shape a window drop produces
|
|
5520
|
+
*/
|
|
5521
|
+
tabDropProbe(e, { sourceLeafId = null } = {}) {
|
|
5522
|
+
const leafEl = this._leafElAt(e.clientX, e.clientY, null);
|
|
5523
|
+
if (!leafEl) return null;
|
|
5524
|
+
const leafId = leafEl.dataset.leafId;
|
|
5525
|
+
const tree = this._tree();
|
|
5526
|
+
const leaf = tree.get(leafId);
|
|
5527
|
+
if (!leaf || leaf.kind !== "leaf") return null;
|
|
5528
|
+
if (String(leaf.content?.kind || "").startsWith("panel:")) return null;
|
|
5529
|
+
const own = !!sourceLeafId && leafId === sourceLeafId;
|
|
5530
|
+
if (own) {
|
|
5531
|
+
const src = tree.get(sourceLeafId);
|
|
5532
|
+
const count = Array.isArray(src?.tabs) ? src.tabs.length : 0;
|
|
5533
|
+
if (count <= 1) return null;
|
|
5534
|
+
}
|
|
5535
|
+
const r = leafEl.getBoundingClientRect();
|
|
5536
|
+
const side = leaf.content ? this._snapSide(r, e, null, false) : null;
|
|
5537
|
+
return this._dropZoneFor({
|
|
5538
|
+
leafId,
|
|
5539
|
+
leaf,
|
|
5540
|
+
r,
|
|
5541
|
+
side,
|
|
5542
|
+
own,
|
|
5543
|
+
desktopIdx: this.desktops.activeIdx
|
|
5544
|
+
});
|
|
5545
|
+
}
|
|
5546
|
+
/**
|
|
5547
|
+
* R15. WHICH EDGE OF THE PANE THE *WINDOW* IS BEING PUSHED INTO.
|
|
5548
|
+
*
|
|
5549
|
+
* ══ THE BUG THIS EXISTS TO FIX ═══════════════════════════════════════
|
|
5550
|
+
*
|
|
5551
|
+
* The band was measured from the POINTER, and the pointer is wherever the
|
|
5552
|
+
* hand happened to grab the title bar. Grab a 900px window in the middle
|
|
5553
|
+
* of its bar and shove it right: `dragBounds` clamps it, its right border
|
|
5554
|
+
* sits hard against the layer's right edge, and the pointer is still 450px
|
|
5555
|
+
* away from that edge — outside every band, so nothing arms and the window
|
|
5556
|
+
* simply stops dead against the side of the screen. Reported twice:
|
|
5557
|
+
* *"snapping enables based on mouse position but actually it needs to
|
|
5558
|
+
* enable based on the dragged window bounds (e.g. window right border
|
|
5559
|
+
* distance from right snapping area)"*.
|
|
5560
|
+
*
|
|
5561
|
+
* The bigger the window the worse it got, and the gesture only ever worked
|
|
5562
|
+
* if you happened to grab near the edge you were aiming at — the bottom
|
|
5563
|
+
* edge was effectively unreachable for any tall window, because a title bar
|
|
5564
|
+
* is at the TOP of the thing you are dragging.
|
|
5565
|
+
*
|
|
5566
|
+
* So each of the four distances is now between the window's own border and
|
|
5567
|
+
* the matching border of the pane. `right` arms when the window's right
|
|
5568
|
+
* border comes within the band of the pane's right border, and so on round.
|
|
5569
|
+
*
|
|
5570
|
+
* ══ WHAT DID *NOT* CHANGE ════════════════════════════════════════════
|
|
5571
|
+
*
|
|
5572
|
+
* WHICH PANE is still the pointer's answer (`_leafElAt`), and so is `own`.
|
|
5573
|
+
* The zone matrix is about a pane — the window's own pane means something
|
|
5574
|
+
* different from any other pane — and a window can lie across three of
|
|
5575
|
+
* them at once while the pointer is in exactly one. Only the question
|
|
5576
|
+
* *"which edge of THIS pane"* moved onto the window's rectangle; the
|
|
5577
|
+
* question *"which pane"* was never the one the product owner complained
|
|
5578
|
+
* about. Everything downstream is untouched: the preview is still
|
|
5579
|
+
* `_halfOf(paneRect, side)`, so the rectangle drawn is the rectangle the
|
|
5580
|
+
* drop delivers, and a `side` reaching the branches below means exactly
|
|
5581
|
+
* what it meant before.
|
|
5582
|
+
*
|
|
5583
|
+
* ══ SHORTFALL CLAMPED AT ZERO, BECAUSE A WINDOW OVERHANGS ═════════════
|
|
5584
|
+
*
|
|
5585
|
+
* (R16 corrects R15 here. R15 said *unsigned*, and unsigned was wrong;
|
|
5586
|
+
* the paragraph below is why, and `_snapSide` carries the measurement.)
|
|
5587
|
+
*
|
|
5588
|
+
* The pointer is inside the pane by construction — `_leafElAt` found the
|
|
5589
|
+
* pane by hit-testing it — so a signed distance was always positive. A
|
|
5590
|
+
* WINDOW has no such guarantee: it is clamped to the layer, not to the
|
|
5591
|
+
* pane, so a window wider than the pane under the pointer sticks out of
|
|
5592
|
+
* both sides of it and its border is 20px PAST the pane's border rather
|
|
5593
|
+
* than 20px short of it. Both readings are "hard against that edge".
|
|
5594
|
+
*
|
|
5595
|
+
* R15 spelled that `Math.abs`, and `Math.abs` only holds the reading while
|
|
5596
|
+
* the overhang stays inside the band. Past that it counts UP again, so the
|
|
5597
|
+
* zone armed and then DISARMED as the shove continued, and a window
|
|
5598
|
+
* meaningfully wider than the pane armed nothing at all. The right spelling
|
|
5599
|
+
* is a shortfall clamped at zero: **past the edge IS the edge**, at
|
|
5600
|
+
* distance zero, and it stays there however far the shove carries it.
|
|
5601
|
+
*
|
|
5602
|
+
* ══ THE DIRECTION GUARD, WHICH IS WHAT KEEPS R2 ALIVE ════════════════
|
|
5603
|
+
*
|
|
5604
|
+
* Edge-based testing has a failure the pointer never had: a window that is
|
|
5605
|
+
* ALREADY at an edge is in that band before the drag starts. A window
|
|
5606
|
+
* parked at the left of its pane would arm a left split on the first
|
|
5607
|
+
* millimetre of any drag, and a window that fills its pane would arm on
|
|
5608
|
+
* every drag in every direction — which is precisely the *"every move
|
|
5609
|
+
* looked like a dock because every move WAS one"* failure `SNAP_EDGE_PX`
|
|
5610
|
+
* was written to end, arriving from the other direction.
|
|
5611
|
+
*
|
|
5612
|
+
* So in the window's OWN pane an edge arms only if the drag actually
|
|
5613
|
+
* carried the window at it: the pointer must have travelled more than one
|
|
5614
|
+
* band's width toward that edge since the press. A nudge (R2's complaint,
|
|
5615
|
+
* and the surviving reason the own-pane centre is silent) moves a handful
|
|
5616
|
+
* of pixels and arms nothing; a shove moves hundreds and arms the edge it
|
|
5617
|
+
* was aimed at. The band's own width is the unit, because a movement
|
|
5618
|
+
* smaller than the band cannot be the difference between being in it and
|
|
5619
|
+
* not.
|
|
5620
|
+
*
|
|
5621
|
+
* IN ANY OTHER PANE THE GUARD IS OFF, deliberately. R2 is a rule about the
|
|
5622
|
+
* pane a window already lives on — the only place a "nudge" exists. Drag a
|
|
5623
|
+
* window rightwards out of pane A and into pane B and its LEFT border is
|
|
5624
|
+
* what enters pane B first: with the guard on, aiming at the left half of
|
|
5625
|
+
* the pane to your right would be impossible, since arriving there always
|
|
5626
|
+
* means travelling right. The window is translucent by then (R3) and every
|
|
5627
|
+
* drop on a foreign pane docks, so there is no nudge to protect.
|
|
5628
|
+
*
|
|
5629
|
+
* The displacement is read from `ManagedWindow._dragState.startX/startY`,
|
|
5630
|
+
* the POINTER's position at the press — not from the window's own x/y,
|
|
5631
|
+
* which stop changing the moment the clamp bites while the gesture very
|
|
5632
|
+
* much continues. A caller with no drag state (a synthetic probe, an
|
|
5633
|
+
* embedder driving the controller by hand) yields no displacement at all
|
|
5634
|
+
* and the guard is skipped rather than failing closed: it can only ever
|
|
5635
|
+
* suppress an edge, never invent one.
|
|
5636
|
+
*
|
|
5637
|
+
* ══ CORNERS: THE PRECEDENCE, MADE EXPLICIT ═══════════════════════════
|
|
5638
|
+
*
|
|
5639
|
+
* Two edges can be in range at once, and with window borders that is no
|
|
5640
|
+
* longer the rarity it was with a pointer — shove a window into a corner
|
|
5641
|
+
* and the clamp puts BOTH borders at distance zero, exactly. Under R16 it
|
|
5642
|
+
* is not even a corner case: a window as wide as its pane is at zero on
|
|
5643
|
+
* the left AND the right for every horizontal position it can occupy, and
|
|
5644
|
+
* a floated canvas pane's window is *exactly* that wide. So the order is
|
|
5645
|
+
* stated rather than left to whichever way the loop happens to run:
|
|
5646
|
+
*
|
|
5647
|
+
* 1. NEAREST WINS. Unchanged from R4, and it is what keeps a corner from
|
|
5648
|
+
* being a dead spot: one of the two is always closer.
|
|
5649
|
+
* 2. ON A TIE, THE EDGE THE DRAG PUSHED TOWARD WINS. (R16: *toward that
|
|
5650
|
+
* edge*, signed — R15 said "the axis pushed furthest" and spelled it
|
|
5651
|
+
* `Math.abs`, which gives the two ends of one axis the SAME score, so
|
|
5652
|
+
* a left/right tie never broke at all and 'left' won every time by
|
|
5653
|
+
* loop order.) The honest tie-break is the gesture: shove it
|
|
5654
|
+
* rightwards and you get the right zone, upwards and you get the top
|
|
5655
|
+
* zone. Every zone stays reachable and which one you get is something
|
|
5656
|
+
* a hand can aim.
|
|
5657
|
+
* 3. STILL TIED — a perfect diagonal, or a probe with no drag state —
|
|
5658
|
+
* falls to the fixed order left, right, top, bottom. That is the order
|
|
5659
|
+
* the R4 loop already resolved ties in (`Object.entries` insertion
|
|
5660
|
+
* order, with a strict `<`), kept so the pointer fallback below
|
|
5661
|
+
* answers exactly what it answered before.
|
|
5662
|
+
*
|
|
5663
|
+
* ══ THE FALLBACK ═════════════════════════════════════════════════════
|
|
5664
|
+
*
|
|
5665
|
+
* With no measurable window rectangle — no element, detached, or a box of
|
|
5666
|
+
* zero area because layout has not happened — there is nothing to measure
|
|
5667
|
+
* and the pointer is the only information in the room. That path is the
|
|
5668
|
+
* pre-R15 code, unchanged, signed distances and all. It is what a headless
|
|
5669
|
+
* probe gets (jsdom lays nothing out, so every `getBoundingClientRect` is
|
|
5670
|
+
* zero), and it is why `web/js/shell/snap_zones.test.mjs` in the Tables
|
|
5671
|
+
* consumer still asserts the same matrix against the same coordinates.
|
|
5672
|
+
*
|
|
5673
|
+
* @param {DOMRect} r the pane, in viewport pixels
|
|
5674
|
+
* @param {{clientX: number, clientY: number}} e the pointer
|
|
5675
|
+
* @param {object} win the ManagedWindow being dragged
|
|
5676
|
+
* @param {boolean} own is `r` the pane this window's drag escaped?
|
|
5677
|
+
* @returns {'left'|'right'|'top'|'bottom'|null}
|
|
5678
|
+
*/
|
|
5679
|
+
_snapSide(r, e, win, own) {
|
|
5680
|
+
const edge = _WindowManager.SNAP_EDGE_PX;
|
|
5681
|
+
const w = this._draggedRect(win);
|
|
5682
|
+
const push = w ? this._dragPush(e, win) : null;
|
|
5683
|
+
const guarded = !!(own && push);
|
|
5684
|
+
const dist = w ? {
|
|
5685
|
+
left: Math.max(0, w.left - r.left),
|
|
5686
|
+
right: Math.max(0, r.right - w.right),
|
|
5687
|
+
top: Math.max(0, w.top - r.top),
|
|
5688
|
+
bottom: Math.max(0, r.bottom - w.bottom)
|
|
5689
|
+
} : {
|
|
5690
|
+
left: e.clientX - r.left,
|
|
5691
|
+
right: r.right - e.clientX,
|
|
5692
|
+
top: e.clientY - r.top,
|
|
5693
|
+
bottom: r.bottom - e.clientY
|
|
5694
|
+
};
|
|
5695
|
+
const toward = (name) => {
|
|
5696
|
+
if (!guarded) return true;
|
|
5697
|
+
if (name === "left") return push.x <= -edge;
|
|
5698
|
+
if (name === "right") return push.x >= edge;
|
|
5699
|
+
if (name === "top") return push.y <= -edge;
|
|
5700
|
+
return push.y >= edge;
|
|
5701
|
+
};
|
|
5702
|
+
const along = (name) => {
|
|
5703
|
+
const dx = push?.x ?? 0, dy = push?.y ?? 0;
|
|
5704
|
+
if (name === "left") return -dx;
|
|
5705
|
+
if (name === "right") return dx;
|
|
5706
|
+
if (name === "top") return -dy;
|
|
5707
|
+
return dy;
|
|
5708
|
+
};
|
|
5709
|
+
let best = null;
|
|
5710
|
+
for (const name of ["left", "right", "top", "bottom"]) {
|
|
5711
|
+
const d = dist[name];
|
|
5712
|
+
if (!(d < edge) || !toward(name)) continue;
|
|
5713
|
+
const p = along(name);
|
|
5714
|
+
if (!best || d < best.d || d === best.d && p > best.p) best = { name, d, p };
|
|
5715
|
+
}
|
|
5716
|
+
return best ? best.name : null;
|
|
5717
|
+
}
|
|
5718
|
+
/** R15. The dragged window's rectangle in VIEWPORT pixels — the frame a
|
|
5719
|
+
* leaf's `getBoundingClientRect` speaks, so the two are directly
|
|
5720
|
+
* comparable — or null when there is nothing to measure.
|
|
5721
|
+
*
|
|
5722
|
+
* Read off the element rather than computed from `win.x/y/width/height`,
|
|
5723
|
+
* because those are in whatever container the window is currently parented
|
|
5724
|
+
* to and mid-drag that is the drag host, not the pane being probed.
|
|
5725
|
+
*
|
|
5726
|
+
* A zero-area box is "nothing to measure" rather than a rectangle at the
|
|
5727
|
+
* origin: it is what an unlaid-out document gives, and treating it as real
|
|
5728
|
+
* would put every window in the top-left corner of every pane. Same test
|
|
5729
|
+
* `_homeDockTarget` applies to a leaf, for the same reason. */
|
|
5730
|
+
_draggedRect(win) {
|
|
5731
|
+
const el = win?.element;
|
|
5732
|
+
if (!el || el.isConnected === false) return null;
|
|
5733
|
+
if (typeof el.getBoundingClientRect !== "function") return null;
|
|
5734
|
+
const b = el.getBoundingClientRect();
|
|
5735
|
+
if (!b || !b.width || !b.height) return null;
|
|
5736
|
+
return b;
|
|
5737
|
+
}
|
|
5738
|
+
/** R15. How far the POINTER has travelled since the press that began this
|
|
5739
|
+
* drag, or null if this window is not in a drag the WM can see.
|
|
5740
|
+
*
|
|
5741
|
+
* The pointer rather than the window: `_applyPosition` clamps the window
|
|
5742
|
+
* to `dragBounds`, so a window shoved at the edge of the layer stops
|
|
5743
|
+
* moving while the gesture continues — and "it stopped because it is
|
|
5744
|
+
* against the edge" is exactly the situation the guard must not read as
|
|
5745
|
+
* "it is not being pushed". */
|
|
5746
|
+
_dragPush(e, win) {
|
|
5747
|
+
const ds = win?._dragState;
|
|
5748
|
+
if (!ds || typeof ds.startX !== "number" || typeof ds.startY !== "number") return null;
|
|
5749
|
+
return { x: e.clientX - ds.startX, y: e.clientY - ds.startY };
|
|
5750
|
+
}
|
|
5751
|
+
/** The topmost `.twm-leaf` under the pointer that is not part of the window
|
|
5752
|
+
* being dragged. `elementsFromPoint` rather than `elementFromPoint`: the
|
|
5753
|
+
* dragged window IS under the pointer — it is what the pointer is holding
|
|
5754
|
+
* — and a hit-test that stops at the first element only ever finds it.
|
|
5755
|
+
*
|
|
5756
|
+
* R15 left this alone on purpose: WHICH pane is still the pointer's
|
|
5757
|
+
* answer, and only WHICH EDGE of it moved onto the window's borders. See
|
|
5758
|
+
* `_snapSide`. */
|
|
5759
|
+
_leafElAt(x, y, win) {
|
|
5760
|
+
const stack = document.elementsFromPoint(x, y);
|
|
5761
|
+
for (const el of stack) {
|
|
5762
|
+
if (win?.element && win.element.contains(el)) continue;
|
|
5763
|
+
const leafEl = el.closest?.(".twm-leaf");
|
|
5764
|
+
if (leafEl && this.rootEl.contains(leafEl)) return leafEl;
|
|
5765
|
+
}
|
|
5766
|
+
return null;
|
|
3538
5767
|
}
|
|
3539
|
-
/**
|
|
3540
|
-
*
|
|
3541
|
-
*
|
|
3542
|
-
*
|
|
5768
|
+
/**
|
|
5769
|
+
* R14. THE TILE "BACK TO TILE" WOULD PUT THIS WINDOW IN, and the rectangle
|
|
5770
|
+
* that draws it — in the VIEWPORT pixels a snap preview is positioned in.
|
|
5771
|
+
*
|
|
5772
|
+
* `bringBackWindow` resolves its destination privately and then closes the
|
|
5773
|
+
* window to reach it, which is everything a button press needs and useless
|
|
5774
|
+
* to a PREVIEW. C15's rule is that the rectangle drawn during a drag is the
|
|
5775
|
+
* one the drop delivers, and under R14 the drop delivers A TILE — so the
|
|
5776
|
+
* resolution has to be readable before the gesture is committed. Reading it
|
|
5777
|
+
* out here is what makes the top edge honest: the probe draws what this
|
|
5778
|
+
* returns and `_snapCommit` calls `bringBackWindow`, which resolves the
|
|
5779
|
+
* same way, from the same record, against the same tree.
|
|
5780
|
+
*
|
|
5781
|
+
* It is deliberately NOT a second copy of that resolution reduced to "the
|
|
5782
|
+
* home leaf". `bringBackWindow`'s fall-through — no home leaf, or one the
|
|
5783
|
+
* tree no longer has — is the desktop's PRIMARY tile, which is where
|
|
5784
|
+
* `_onManagedWindowClosed` sends a demotion carrying no `_dock`; a probe
|
|
5785
|
+
* that previewed the home leaf and then landed in the primary tile would be
|
|
5786
|
+
* the bait-and-switch with extra steps.
|
|
5787
|
+
*
|
|
5788
|
+
* `renderer.leafEl` only knows the leaves of the desktop currently on
|
|
5789
|
+
* screen, which is the property that makes the desktop check implicit: a
|
|
5790
|
+
* window whose home is on another desktop resolves to no element, this
|
|
5791
|
+
* answers null, and the top edge arms nothing rather than previewing a
|
|
5792
|
+
* rectangle on a desktop the user cannot see.
|
|
5793
|
+
*
|
|
5794
|
+
* @param {object} win a live ManagedWindow
|
|
5795
|
+
* @returns {{leafId: string, rect: DOMRect}|null} null when there is
|
|
5796
|
+
* nothing honest to promise: a window the WM never adopted, a desktop
|
|
5797
|
+
* that has gone, a tree with no content leaf at all, or a leaf with no
|
|
5798
|
+
* measurable box (a layout that has not happened yet).
|
|
5799
|
+
*/
|
|
5800
|
+
_homeDockTarget(win) {
|
|
5801
|
+
const rec = [...this._windowToLeaf.values()].find((r) => r.window === win);
|
|
5802
|
+
if (!rec) return null;
|
|
5803
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
5804
|
+
if (!tree) return null;
|
|
5805
|
+
const home = rec.homeLeafId ? tree.get(rec.homeLeafId) : null;
|
|
5806
|
+
const leafId = home && home.kind === "leaf" ? rec.homeLeafId : tree.primaryLeafId();
|
|
5807
|
+
if (!leafId) return null;
|
|
5808
|
+
const el = this.renderer.leafEl(leafId);
|
|
5809
|
+
if (!el) return null;
|
|
5810
|
+
const rect = el.getBoundingClientRect();
|
|
5811
|
+
if (!rect.width || !rect.height) return null;
|
|
5812
|
+
return { leafId, rect };
|
|
5813
|
+
}
|
|
5814
|
+
_snapCommit(probe, win) {
|
|
5815
|
+
if (!probe) return false;
|
|
5816
|
+
const rec = [...this._windowToLeaf.entries()].find(([, r]) => r.window === win);
|
|
5817
|
+
if (!rec) return false;
|
|
5818
|
+
const [winId, record] = rec;
|
|
5819
|
+
const { leafId, desktopIdx, side } = probe;
|
|
5820
|
+
if (probe.mode === "home") {
|
|
5821
|
+
if (!this.bringBackWindow(winId)) return false;
|
|
5822
|
+
win.clearSnapPreview();
|
|
5823
|
+
return true;
|
|
5824
|
+
}
|
|
5825
|
+
if (typeof desktopIdx === "number") record.desktopIdx = desktopIdx;
|
|
5826
|
+
const docked = this.dockWindowInto(winId, {
|
|
5827
|
+
leafId,
|
|
5828
|
+
mode: probe.mode || "split",
|
|
5829
|
+
dir: side === "left" || side === "right" ? "h" : "v",
|
|
5830
|
+
before: side === "left" || side === "top"
|
|
5831
|
+
});
|
|
5832
|
+
if (!docked) return false;
|
|
5833
|
+
win.clearSnapPreview();
|
|
5834
|
+
return true;
|
|
5835
|
+
}
|
|
5836
|
+
/**
|
|
5837
|
+
* Put a floating window's content back into the tree at a NAMED place.
|
|
5838
|
+
*
|
|
5839
|
+
* `bringBackWindow` is this with `{ mode: 'tab' }` against the primary tile
|
|
5840
|
+
* — the answer when the user pressed a button in the window's own chrome
|
|
5841
|
+
* and named no destination. A drop names one.
|
|
5842
|
+
*
|
|
5843
|
+
* The window is CLOSED to do it, exactly as a demote is: the content
|
|
5844
|
+
* factory re-mounts inside the tile, and a factory that must not lose live
|
|
5845
|
+
* state across that boundary is the embedder's problem to solve (it is why
|
|
5846
|
+
* the registry is keyed on (kind, props) rather than on a DOM node).
|
|
5847
|
+
*
|
|
5848
|
+
* @param {string} windowId
|
|
5849
|
+
* @param {{leafId: string, mode: 'fill'|'tab'|'split', dir?: 'h'|'v',
|
|
5850
|
+
* before?: boolean}} target
|
|
5851
|
+
*/
|
|
5852
|
+
dockWindowInto(windowId, target) {
|
|
5853
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
5854
|
+
if (!rec || !target?.leafId) return false;
|
|
5855
|
+
rec._dock = { ...target };
|
|
5856
|
+
rec._demoting = true;
|
|
5857
|
+
try {
|
|
5858
|
+
rec.window.close({ force: true });
|
|
5859
|
+
} catch (err) {
|
|
5860
|
+
console.warn("[wm] dock: close failed", err);
|
|
5861
|
+
return false;
|
|
5862
|
+
}
|
|
5863
|
+
return true;
|
|
5864
|
+
}
|
|
5865
|
+
/**
|
|
5866
|
+
* Move a managed window to another desktop.
|
|
5867
|
+
*
|
|
5868
|
+
* ══ IT USED TO REWRITE ONE INTEGER, AND THAT MOVED NOTHING ═════════
|
|
5869
|
+
*
|
|
5870
|
+
* The sentence that stood here — *"the window itself stays on screen
|
|
5871
|
+
* (managed windows are global)"* — was true of a window floating over the
|
|
5872
|
+
* root and false of every window this WM promotes under `promoteInPlace`,
|
|
5873
|
+
* which is CONTAINED IN A TILE (C21). Rewriting `desktopIdx` left such a
|
|
5874
|
+
* window standing in the pane it was already in, on the page the user was
|
|
5875
|
+
* already looking at: *Move to desktop Views* appeared to do nothing at
|
|
5876
|
+
* all. Then *Back to tile* resolved against the new desktop's tree and
|
|
5877
|
+
* docked the content onto a page nobody was watching, so the window
|
|
5878
|
+
* vanished here and its table turned up over there.
|
|
5879
|
+
*
|
|
5880
|
+
* Three things move it for real. The index, so every later resolution
|
|
5881
|
+
* agrees. The HOME LEAF, re-pointed at a ground that exists in the
|
|
5882
|
+
* destination — without it the record names a leaf of the tree it just
|
|
5883
|
+
* left, and `_rehomeContainedWindows` would either skip it forever or
|
|
5884
|
+
* repair it to a pane on the wrong page. And a render, which is where the
|
|
5885
|
+
* element is taken off the page the window has left (or parented into its
|
|
5886
|
+
* new ground, when the destination is the desktop on screen).
|
|
5887
|
+
*
|
|
5888
|
+
* An ADOPTED window (`homeContainer`) keeps its own resolution: the
|
|
5889
|
+
* embedder owns the box it stands in, and re-pointing a leaf id it does not
|
|
5890
|
+
* read would be a change with no effect wearing the look of one.
|
|
5891
|
+
*/
|
|
3543
5892
|
moveWindowToDesktop(windowId, targetIdx) {
|
|
3544
5893
|
const rec = this._windowToLeaf.get(windowId);
|
|
3545
5894
|
if (!rec) return;
|
|
3546
5895
|
if (rec.desktopIdx === targetIdx) return;
|
|
3547
5896
|
this.desktops.ensureCount(targetIdx + 1);
|
|
3548
5897
|
rec.desktopIdx = targetIdx;
|
|
5898
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
5899
|
+
const target = this.desktops.desktops[targetIdx]?.tree;
|
|
5900
|
+
rec.homeLeafId = target?.primaryLeafId() || null;
|
|
5901
|
+
}
|
|
5902
|
+
this.renderer.render();
|
|
3549
5903
|
this._persist();
|
|
3550
5904
|
this._notifyChange("window-moved");
|
|
3551
5905
|
}
|
|
5906
|
+
/**
|
|
5907
|
+
* R8. Put a floated pane's tabs back into a leaf — ALL of them, in the
|
|
5908
|
+
* order they had, with the one that was showing still showing.
|
|
5909
|
+
*
|
|
5910
|
+
* Every dock goes through here, and that is the point: `bringBackWindow`,
|
|
5911
|
+
* a drop on a tile's body, a drop on an edge and the fall-through when the
|
|
5912
|
+
* named destination vanished are four routes to one question — *where do
|
|
5913
|
+
* these tabs go* — and four copies of the answer would disagree about the
|
|
5914
|
+
* third one within a release. A window promoted before R8 (or by
|
|
5915
|
+
* `_navigateWindow`, which never had tabs) carries no list, so `original`
|
|
5916
|
+
* is the fallback and the single-tab path reduces to exactly what this
|
|
5917
|
+
* replaced.
|
|
5918
|
+
*
|
|
5919
|
+
* `replace` is the difference between filling a leaf and joining one: a
|
|
5920
|
+
* fresh split leaf and a `fill` drop want the first tab to BECOME the
|
|
5921
|
+
* leaf's content, while a `tab` drop and "back to tile" append beside what
|
|
5922
|
+
* is already there.
|
|
5923
|
+
*/
|
|
5924
|
+
_restoreTabs(tree, leafId, rec, { replace }) {
|
|
5925
|
+
if (!leafId) return false;
|
|
5926
|
+
const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [{
|
|
5927
|
+
kind: rec.original.kind,
|
|
5928
|
+
props: rec.original.props,
|
|
5929
|
+
title: rec.original.title
|
|
5930
|
+
}];
|
|
5931
|
+
const active = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
|
|
5932
|
+
let firstIdx = -1;
|
|
5933
|
+
tabs.forEach((tab, i) => {
|
|
5934
|
+
const content = { kind: tab.kind, props: tab.props || {} };
|
|
5935
|
+
const title = tab.title || tab.kind || "";
|
|
5936
|
+
if (i === 0 && replace) {
|
|
5937
|
+
tree.setLeafContent(leafId, content, title);
|
|
5938
|
+
firstIdx = 0;
|
|
5939
|
+
return;
|
|
5940
|
+
}
|
|
5941
|
+
const at = tree.appendLeafTab(leafId, content, title);
|
|
5942
|
+
if (at >= 0 && firstIdx < 0) firstIdx = at;
|
|
5943
|
+
});
|
|
5944
|
+
if (firstIdx < 0) return false;
|
|
5945
|
+
tree.setActiveLeafTab(leafId, firstIdx + active);
|
|
5946
|
+
tree.focus(leafId);
|
|
5947
|
+
return true;
|
|
5948
|
+
}
|
|
3552
5949
|
_onManagedWindowClosed(winId, mountInfo) {
|
|
3553
5950
|
const rec = this._windowToLeaf.get(winId);
|
|
3554
5951
|
this._windowToLeaf.delete(winId);
|
|
3555
5952
|
if (!rec) return;
|
|
3556
5953
|
try {
|
|
3557
|
-
mountInfo?.destroy?.();
|
|
5954
|
+
(rec.mountInfo || mountInfo)?.destroy?.();
|
|
5955
|
+
} catch {
|
|
5956
|
+
}
|
|
5957
|
+
try {
|
|
5958
|
+
rec.strip?.dispose();
|
|
3558
5959
|
} catch {
|
|
3559
5960
|
}
|
|
3560
5961
|
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
3561
5962
|
if (!tree) return;
|
|
3562
|
-
if (rec._demoting) {
|
|
5963
|
+
if (rec._demoting && rec._dock) {
|
|
5964
|
+
const target = tree.get(rec._dock.leafId);
|
|
5965
|
+
if (target && target.kind === "leaf") {
|
|
5966
|
+
const { mode, dir, before } = rec._dock;
|
|
5967
|
+
if (mode === "tab") {
|
|
5968
|
+
this._restoreTabs(tree, rec._dock.leafId, rec, { replace: false });
|
|
5969
|
+
} else if (mode === "split") {
|
|
5970
|
+
const newId = tree.split(rec._dock.leafId, dir);
|
|
5971
|
+
if (newId) {
|
|
5972
|
+
this._restoreTabs(tree, newId, rec, { replace: true });
|
|
5973
|
+
_halveInto(tree, rec._dock.leafId, newId);
|
|
5974
|
+
if (before) _swapSiblings(tree, rec._dock.leafId, newId);
|
|
5975
|
+
tree.focus(newId);
|
|
5976
|
+
}
|
|
5977
|
+
} else {
|
|
5978
|
+
this._restoreTabs(tree, rec._dock.leafId, rec, { replace: true });
|
|
5979
|
+
}
|
|
5980
|
+
} else {
|
|
5981
|
+
this._restoreTabs(tree, tree.primaryLeafId(), rec, { replace: false });
|
|
5982
|
+
}
|
|
5983
|
+
} else if (rec._demoting) {
|
|
3563
5984
|
let pid = tree.primaryLeafId();
|
|
3564
5985
|
let spawned = false;
|
|
3565
5986
|
if (!pid) {
|
|
@@ -3567,12 +5988,7 @@ var WindowManager = class {
|
|
|
3567
5988
|
spawned = true;
|
|
3568
5989
|
}
|
|
3569
5990
|
if (pid) {
|
|
3570
|
-
|
|
3571
|
-
pid,
|
|
3572
|
-
{ kind: rec.original.kind, props: rec.original.props },
|
|
3573
|
-
rec.original.title
|
|
3574
|
-
);
|
|
3575
|
-
tree.focus(pid);
|
|
5991
|
+
this._restoreTabs(tree, pid, rec, { replace: false });
|
|
3576
5992
|
if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
3577
5993
|
}
|
|
3578
5994
|
}
|
|
@@ -3580,24 +5996,26 @@ var WindowManager = class {
|
|
|
3580
5996
|
this._persist();
|
|
3581
5997
|
this._notifyChange(rec._demoting ? "window-demoted" : "window-closed");
|
|
3582
5998
|
}
|
|
3583
|
-
/** Post-show DOM hook:
|
|
3584
|
-
*
|
|
5999
|
+
/** Post-show DOM hook: wire a right-click context menu on the topbar.
|
|
6000
|
+
*
|
|
6001
|
+
* R7. IT USED TO INJECT A BUTTON HERE, and that is the whole of what
|
|
6002
|
+
* changed. "Back to tile" was a fourth button squeezed left of Close,
|
|
6003
|
+
* built by reaching into four of ManagedWindow's internal class names —
|
|
6004
|
+
* the coupling C6 exists to avoid — and it sat next to a MAXIMIZE button
|
|
6005
|
+
* that did the one thing a window lifted out of a tile has no use for.
|
|
6006
|
+
* Now the maximize button IS "back to tile" (`onMaximize`, passed where
|
|
6007
|
+
* the window is built), so the verb has one control instead of two and
|
|
6008
|
+
* this hook has no markup of its own to keep in step.
|
|
6009
|
+
*
|
|
6010
|
+
* Gone with it: the rule that hid MINIMIZE while the window was maximised.
|
|
6011
|
+
* It existed because minimising a full-screen window strands it — nothing
|
|
6012
|
+
* on screen points at it any more — and a window that cannot maximise
|
|
6013
|
+
* cannot be in that state at all. `managed-window-maximized` (C16) still
|
|
6014
|
+
* fires for everyone else, and `--suppressed` is still styled for the next
|
|
6015
|
+
* consumer that needs to hide one of these. */
|
|
3585
6016
|
_decorateManagedWindow(win, winId) {
|
|
3586
6017
|
const topbar = win.element?.querySelector?.(".twm-managed-window__topbar");
|
|
3587
|
-
|
|
3588
|
-
if (!buttons) return;
|
|
3589
|
-
const backBtn = document.createElement("button");
|
|
3590
|
-
backBtn.type = "button";
|
|
3591
|
-
backBtn.className = "twm-managed-window__btn managed-window__btn--demote";
|
|
3592
|
-
backBtn.title = "Back to tile";
|
|
3593
|
-
backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
|
|
3594
|
-
backBtn.addEventListener("click", (e) => {
|
|
3595
|
-
e.stopPropagation();
|
|
3596
|
-
this.bringBackWindow(winId);
|
|
3597
|
-
});
|
|
3598
|
-
const closeBtn = buttons.querySelector(".twm-managed-window__btn--close");
|
|
3599
|
-
if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
|
|
3600
|
-
else buttons.appendChild(backBtn);
|
|
6018
|
+
if (!topbar) return;
|
|
3601
6019
|
topbar.addEventListener("contextmenu", (e) => {
|
|
3602
6020
|
e.preventDefault();
|
|
3603
6021
|
const rec = this._windowToLeaf.get(winId);
|
|
@@ -3636,6 +6054,135 @@ var WindowManager = class {
|
|
|
3636
6054
|
});
|
|
3637
6055
|
});
|
|
3638
6056
|
}
|
|
6057
|
+
// ══ R10. Adjacent start tiles are one start tile ═══════════════════
|
|
6058
|
+
/**
|
|
6059
|
+
* Merge every run of side-by-side START TILES into one.
|
|
6060
|
+
*
|
|
6061
|
+
* A start tile is a leaf holding the taxonomy ROOT — the pane a fresh
|
|
6062
|
+
* desktop opens with, and the pane `_seedHome` puts back when a tile is
|
|
6063
|
+
* emptied. It is not a document: it is the ground, the empty canvas, the
|
|
6064
|
+
* "nothing is open here" surface. So two of them side by side are ONE
|
|
6065
|
+
* surface with a splitter drawn through it for no reason, and the splitter
|
|
6066
|
+
* is worse than decoration — it offers to resize a boundary between two
|
|
6067
|
+
* things that are the same thing.
|
|
6068
|
+
*
|
|
6069
|
+
* This is deliberately NOT run on every tree change, and the reason is
|
|
6070
|
+
* `split()`: splitting a start tile seeds the new pane with the root kind
|
|
6071
|
+
* too (the never-empty-tile invariant), so a merge on every mutation would
|
|
6072
|
+
* undo an Alt+H the instant it happened. It runs where the product owner
|
|
6073
|
+
* put it — *"when a maximized (tiled) panel is window-ized, all adjacent
|
|
6074
|
+
* non-panel (start tile) tiles get merged to one"* — and is public so an
|
|
6075
|
+
* embedder that empties a pane its own way can ask for the same tidy-up.
|
|
6076
|
+
*
|
|
6077
|
+
* THREE THINGS ARE NEVER MERGED, and each one is a way to lose work:
|
|
6078
|
+
*
|
|
6079
|
+
* - `panel:*` leaves. They are chrome, not content; the navigator is not
|
|
6080
|
+
* a start tile and a panel BETWEEN two start tiles means those two are
|
|
6081
|
+
* not adjacent.
|
|
6082
|
+
* - A start tile with WINDOWS STANDING ON IT. The whole point of the
|
|
6083
|
+
* surface is that things float on it, and closing the leaf takes its
|
|
6084
|
+
* ground — and every window clamped to it — out of the document. When
|
|
6085
|
+
* one of a pair is occupied the other merges INTO it; when both are,
|
|
6086
|
+
* neither moves.
|
|
6087
|
+
* - A start tile holding tabs, live or archived. `leaf.content.kind`
|
|
6088
|
+
* names the ACTIVE tab only, and a pane whose other tabs are tables, or
|
|
6089
|
+
* whose `pageTabs` archive holds the three tables a rail click put
|
|
6090
|
+
* there, is a pane with work in it wearing a start tile's face.
|
|
6091
|
+
*
|
|
6092
|
+
* @param {TileTree} tree
|
|
6093
|
+
* @param {string|null} preferLeafId the leaf to keep when a run is
|
|
6094
|
+
* otherwise a free choice — the pane the caller just emptied, so the
|
|
6095
|
+
* merged surface is the one the user is looking at.
|
|
6096
|
+
* @returns {number} how many leaves were absorbed.
|
|
6097
|
+
*/
|
|
6098
|
+
_mergeStartTiles(tree, preferLeafId = null) {
|
|
6099
|
+
if (!tree) return 0;
|
|
6100
|
+
let absorbed = 0;
|
|
6101
|
+
for (; ; ) {
|
|
6102
|
+
const pair = this._nextMergeablePair(tree, preferLeafId);
|
|
6103
|
+
if (!pair) break;
|
|
6104
|
+
const { split, keepIdx, dropIdx, keepId, dropId } = pair;
|
|
6105
|
+
split.sizes[keepIdx] = (split.sizes[keepIdx] || 1) + (split.sizes[dropIdx] || 1);
|
|
6106
|
+
const hadFocus = tree.focusedLeafId === dropId;
|
|
6107
|
+
tree.close(dropId);
|
|
6108
|
+
if (tree.nodes.has(dropId)) break;
|
|
6109
|
+
if (hadFocus) tree.focus(keepId);
|
|
6110
|
+
absorbed += 1;
|
|
6111
|
+
}
|
|
6112
|
+
return absorbed;
|
|
6113
|
+
}
|
|
6114
|
+
/** The first two adjacent start tiles that may be merged, and which of
|
|
6115
|
+
* them survives. Null when there are none. */
|
|
6116
|
+
_nextMergeablePair(tree, preferLeafId) {
|
|
6117
|
+
for (const node of [...tree.nodes.values()]) {
|
|
6118
|
+
if (node.kind !== "split") continue;
|
|
6119
|
+
if (!tree.nodes.has(node.id)) continue;
|
|
6120
|
+
for (let i = 0; i < node.children.length - 1; i += 1) {
|
|
6121
|
+
const a = tree.get(node.children[i]);
|
|
6122
|
+
const b = tree.get(node.children[i + 1]);
|
|
6123
|
+
if (!this._isStartTile(a) || !this._isStartTile(b)) continue;
|
|
6124
|
+
const aHolds = this._paneHoldsWindows(a.id);
|
|
6125
|
+
const bHolds = this._paneHoldsWindows(b.id);
|
|
6126
|
+
if (aHolds && bHolds) continue;
|
|
6127
|
+
let keepIdx = i;
|
|
6128
|
+
if (bHolds) keepIdx = i + 1;
|
|
6129
|
+
else if (!aHolds && node.children[i + 1] === preferLeafId) keepIdx = i + 1;
|
|
6130
|
+
const dropIdx = keepIdx === i ? i + 1 : i;
|
|
6131
|
+
return {
|
|
6132
|
+
split: node,
|
|
6133
|
+
keepIdx,
|
|
6134
|
+
dropIdx,
|
|
6135
|
+
keepId: node.children[keepIdx],
|
|
6136
|
+
dropId: node.children[dropIdx]
|
|
6137
|
+
};
|
|
6138
|
+
}
|
|
6139
|
+
}
|
|
6140
|
+
return null;
|
|
6141
|
+
}
|
|
6142
|
+
/** Is this leaf the empty ground and nothing else? See the three
|
|
6143
|
+
* exclusions in `_mergeStartTiles`. */
|
|
6144
|
+
_isStartTile(leaf) {
|
|
6145
|
+
if (!leaf || leaf.kind !== "leaf") return false;
|
|
6146
|
+
const kind = leaf.content?.kind;
|
|
6147
|
+
if (!kind || kind !== this.taxonomy.root) return false;
|
|
6148
|
+
if (PANEL_KINDS.has(kind) || kind === PLACEHOLDER_KIND) return false;
|
|
6149
|
+
if ((Array.isArray(leaf.tabs) ? leaf.tabs.length : 0) > 1) return false;
|
|
6150
|
+
for (const page of Object.values(leaf.pageTabs || {})) {
|
|
6151
|
+
if (Array.isArray(page?.tabs) && page.tabs.length) return false;
|
|
6152
|
+
}
|
|
6153
|
+
return true;
|
|
6154
|
+
}
|
|
6155
|
+
/**
|
|
6156
|
+
* Does anything float on this pane?
|
|
6157
|
+
*
|
|
6158
|
+
* Two sources, because there are two kinds of window and the WM only knows
|
|
6159
|
+
* about one of them. `homeLeafId` is set for a window this WM contained in
|
|
6160
|
+
* its own pane (C21); an EMBEDDER's windows — a canvas pane that opens its
|
|
6161
|
+
* own `ManagedWindow` against the pane's ground — are not in
|
|
6162
|
+
* `_windowToLeaf` at all, and the only honest way to see them is to look.
|
|
6163
|
+
* The DOM answer covers both, and covers a window whose record has been
|
|
6164
|
+
* dropped but whose element is still standing.
|
|
6165
|
+
*/
|
|
6166
|
+
_paneHoldsWindows(leafId) {
|
|
6167
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
6168
|
+
if (rec.homeLeafId === leafId) return true;
|
|
6169
|
+
}
|
|
6170
|
+
const el = this.renderer.leafEl?.(leafId);
|
|
6171
|
+
return !!el?.querySelector?.(".twm-managed-window");
|
|
6172
|
+
}
|
|
6173
|
+
/** R10, as a verb an embedder can use. Merges, then repaints and persists
|
|
6174
|
+
* — the promote path calls `_mergeStartTiles` directly because it is
|
|
6175
|
+
* already going to do all three. */
|
|
6176
|
+
mergeStartTiles(preferLeafId = null) {
|
|
6177
|
+
const tree = this._tree();
|
|
6178
|
+
const absorbed = this._mergeStartTiles(tree, preferLeafId);
|
|
6179
|
+
if (!absorbed) return 0;
|
|
6180
|
+
this._canonicalize(tree, this.desktops.active());
|
|
6181
|
+
this.renderer.render();
|
|
6182
|
+
this._persist();
|
|
6183
|
+
this._notifyChange("start-tiles-merged");
|
|
6184
|
+
return absorbed;
|
|
6185
|
+
}
|
|
3639
6186
|
// ── Panel-tiles (left nav / right / bottom) ─────────────────────
|
|
3640
6187
|
/** Panels are virtual: they live in the active desktop's tree as
|
|
3641
6188
|
* leaves with content kinds 'panel:left', 'panel:right',
|
|
@@ -3800,12 +6347,42 @@ var WindowManager = class {
|
|
|
3800
6347
|
const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
|
|
3801
6348
|
this._leafTabAction(leafId, "switch", { idx: next });
|
|
3802
6349
|
}
|
|
6350
|
+
/**
|
|
6351
|
+
* Remove a desktop — AND RE-INDEX THE WINDOWS, which is the half that was
|
|
6352
|
+
* missing.
|
|
6353
|
+
*
|
|
6354
|
+
* `desktopIdx` on a window record is an ARRAY INDEX into `desktops`, so a
|
|
6355
|
+
* splice silently re-points every record above the removed one at its
|
|
6356
|
+
* neighbour. Nothing threw and nothing looked wrong: the window kept
|
|
6357
|
+
* floating, and the next *Back to tile* resolved `rec._dock` against the
|
|
6358
|
+
* WRONG TREE. `_onManagedWindowClosed` reads `desktops[rec.desktopIdx]`,
|
|
6359
|
+
* finds a tree that never held this window, and its `if (!tree) return`
|
|
6360
|
+
* closes the window and drops the content on the floor — staged edits
|
|
6361
|
+
* included, with no error and nothing on screen to say a table was lost.
|
|
6362
|
+
*
|
|
6363
|
+
* Windows homed on the desktop being removed do not die with it. The
|
|
6364
|
+
* ruling is that a tile operation may move a window and never destroy it,
|
|
6365
|
+
* and removing a desktop is the largest tile operation there is: they come
|
|
6366
|
+
* across to the desktop that ends up active, re-homed onto its ground by
|
|
6367
|
+
* `_rehomeContainedWindows` on the render below.
|
|
6368
|
+
*/
|
|
3803
6369
|
removeDesktop(idx) {
|
|
3804
6370
|
const m = this.desktops;
|
|
3805
6371
|
if (m.desktops.length <= 1) return false;
|
|
3806
6372
|
if (idx < 0 || idx >= m.desktops.length) return false;
|
|
3807
6373
|
m.desktops.splice(idx, 1);
|
|
6374
|
+
if (idx < m.activeIdx) m.activeIdx -= 1;
|
|
3808
6375
|
if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
|
|
6376
|
+
for (const [, rec] of this._windowToLeaf) {
|
|
6377
|
+
if (rec.desktopIdx === idx) {
|
|
6378
|
+
rec.desktopIdx = m.activeIdx;
|
|
6379
|
+
if (!rec.homeContainer && rec.homeLeafId) {
|
|
6380
|
+
rec.homeLeafId = m.active().tree.primaryLeafId() || null;
|
|
6381
|
+
}
|
|
6382
|
+
} else if (rec.desktopIdx > idx) {
|
|
6383
|
+
rec.desktopIdx -= 1;
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
3809
6386
|
this.renderer.tree = m.active().tree;
|
|
3810
6387
|
this.renderer.render();
|
|
3811
6388
|
this._persist();
|
|
@@ -3816,6 +6393,8 @@ var WindowManager = class {
|
|
|
3816
6393
|
const tree = this._tree();
|
|
3817
6394
|
const focused = tree.focused();
|
|
3818
6395
|
if (!focused || !focused.content) return;
|
|
6396
|
+
const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
|
|
6397
|
+
if (closeChrome === false || closeChrome?.disabled === true) return;
|
|
3819
6398
|
const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
|
|
3820
6399
|
this.desktops.ensureCount(idx + 1);
|
|
3821
6400
|
const target = this.desktops.desktops[idx];
|
|
@@ -3824,6 +6403,11 @@ var WindowManager = class {
|
|
|
3824
6403
|
if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
|
|
3825
6404
|
tree.close(focused.id);
|
|
3826
6405
|
if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
|
|
6406
|
+
if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
|
|
6407
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
6408
|
+
if (spawned) this._seedHome(tree, spawned);
|
|
6409
|
+
}
|
|
6410
|
+
this._canonicalize(tree, this.desktops.active());
|
|
3827
6411
|
this.renderer.render();
|
|
3828
6412
|
this._persist();
|
|
3829
6413
|
this._notifyChange();
|
|
@@ -3880,6 +6464,14 @@ var WindowManager = class {
|
|
|
3880
6464
|
this._showTabContextMenu(leafId, data.idx, data.x, data.y);
|
|
3881
6465
|
return;
|
|
3882
6466
|
}
|
|
6467
|
+
if (action === "to-window") {
|
|
6468
|
+
this.floatTabAsWindow(leafId, data.idx);
|
|
6469
|
+
return;
|
|
6470
|
+
}
|
|
6471
|
+
if (action === "drop-into") {
|
|
6472
|
+
this.moveTabInto(leafId, data.idx, data.target);
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
3883
6475
|
if (action === "close-others") {
|
|
3884
6476
|
tree.closeOtherTabs(leafId, data.idx);
|
|
3885
6477
|
this.renderer.render();
|
|
@@ -4031,6 +6623,33 @@ var WindowManager = class {
|
|
|
4031
6623
|
canMaximize: true,
|
|
4032
6624
|
canResize: true,
|
|
4033
6625
|
modal: false,
|
|
6626
|
+
// C15, and this is the SECOND of the two places the WM builds a
|
|
6627
|
+
// window. Alt+N and "Open in new window" produce a window that is
|
|
6628
|
+
// every bit as dockable as a promoted one, and a window that can be
|
|
6629
|
+
// dragged onto a tile in one case and not the other is a rule
|
|
6630
|
+
// nobody can learn.
|
|
6631
|
+
snap: this.snapPromotion,
|
|
6632
|
+
snapController: this.snapPromotion ? this._snapController() : null,
|
|
6633
|
+
// R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
|
|
6634
|
+
// to one (C21) cannot be dragged a single pixel outside it, so
|
|
6635
|
+
// "drag a window from one tile to another" — the gesture all three
|
|
6636
|
+
// drop behaviours are built on — was not merely awkward, it was
|
|
6637
|
+
// invisible. For the length of a drag the window is re-parented
|
|
6638
|
+
// here, to the root every tile is inside; on release it goes back
|
|
6639
|
+
// into a pane, either the one it was dropped on or the one it came
|
|
6640
|
+
// from. Resolved per drag: the root outlives any tile, and a tile
|
|
6641
|
+
// grabbed once does not survive its own repaint.
|
|
6642
|
+
dragHost: () => this.rootEl,
|
|
6643
|
+
dragBounds: () => this._tileBounds(),
|
|
6644
|
+
// R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
|
|
6645
|
+
// tree; the useful thing to do with it is put it back, and filling
|
|
6646
|
+
// the screen with it is the one gesture that makes putting it back
|
|
6647
|
+
// harder. So the maximize button docks — and the separate demote
|
|
6648
|
+
// button the WM used to inject beside it is gone, because two
|
|
6649
|
+
// buttons for one verb is how you get a chrome nobody reads.
|
|
6650
|
+
onMaximize: () => this.bringBackWindow(winId),
|
|
6651
|
+
maximizeIcon: "close_fullscreen",
|
|
6652
|
+
maximizeTitle: "Back to tile",
|
|
4034
6653
|
onClose: () => this._onManagedWindowClosed(winId, mountInfo)
|
|
4035
6654
|
});
|
|
4036
6655
|
this._windowToLeaf.set(winId, {
|
|
@@ -4068,9 +6687,13 @@ var WindowManager = class {
|
|
|
4068
6687
|
if (!leaf || leaf.kind !== "leaf") return;
|
|
4069
6688
|
const tabs = leaf.tabs || [];
|
|
4070
6689
|
if (tabs.length === 0) return;
|
|
4071
|
-
const items = [
|
|
4072
|
-
|
|
4073
|
-
|
|
6690
|
+
const items = [];
|
|
6691
|
+
const tab = tabs[idx];
|
|
6692
|
+
if (tab && !String(tab.kind || "").startsWith("panel:") && tab.kind !== PLACEHOLDER_KIND) {
|
|
6693
|
+
items.push({ label: "Open in a window", icon: "web_asset", action: "to-window" });
|
|
6694
|
+
items.push({ separator: true });
|
|
6695
|
+
}
|
|
6696
|
+
items.push({ label: "Close tab", icon: "close", action: "close" });
|
|
4074
6697
|
if (tabs.length > 1) {
|
|
4075
6698
|
items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
|
|
4076
6699
|
}
|
|
@@ -4142,6 +6765,66 @@ function _tabTitle(kind, props) {
|
|
|
4142
6765
|
if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
|
|
4143
6766
|
return kind || "";
|
|
4144
6767
|
}
|
|
6768
|
+
function _leafTabSpecs(leaf) {
|
|
6769
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
6770
|
+
if (tabs.length) {
|
|
6771
|
+
return tabs.map((t) => ({
|
|
6772
|
+
kind: t.kind,
|
|
6773
|
+
props: { ...t.props || {} },
|
|
6774
|
+
title: t.title || t.kind || ""
|
|
6775
|
+
}));
|
|
6776
|
+
}
|
|
6777
|
+
return [{
|
|
6778
|
+
kind: leaf.content.kind,
|
|
6779
|
+
props: { ...leaf.content.props || {} },
|
|
6780
|
+
title: leaf.title || leaf.content.kind || ""
|
|
6781
|
+
}];
|
|
6782
|
+
}
|
|
6783
|
+
function _halfOf(r, side) {
|
|
6784
|
+
const w = Math.round(r.width / 2);
|
|
6785
|
+
const h = Math.round(r.height / 2);
|
|
6786
|
+
switch (side) {
|
|
6787
|
+
case "left":
|
|
6788
|
+
return { left: r.left, top: r.top, width: w, height: r.height };
|
|
6789
|
+
case "right":
|
|
6790
|
+
return { left: r.left + r.width - w, top: r.top, width: w, height: r.height };
|
|
6791
|
+
case "top":
|
|
6792
|
+
return { left: r.left, top: r.top, width: r.width, height: h };
|
|
6793
|
+
case "bottom":
|
|
6794
|
+
return { left: r.left, top: r.top + r.height - h, width: r.width, height: h };
|
|
6795
|
+
default:
|
|
6796
|
+
return { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
function _halveInto(tree, sourceId, newId) {
|
|
6800
|
+
const src = tree.get(sourceId);
|
|
6801
|
+
if (!src) return false;
|
|
6802
|
+
const parent = tree.get(src.parentId);
|
|
6803
|
+
if (!parent || parent.kind !== "split") return false;
|
|
6804
|
+
const i = parent.children.indexOf(sourceId);
|
|
6805
|
+
const j = parent.children.indexOf(newId);
|
|
6806
|
+
if (i < 0 || j < 0) return false;
|
|
6807
|
+
const share = (parent.sizes[i] ?? 1) / 2;
|
|
6808
|
+
parent.sizes[i] = share;
|
|
6809
|
+
parent.sizes[j] = share;
|
|
6810
|
+
return true;
|
|
6811
|
+
}
|
|
6812
|
+
function _swapSiblings(tree, aId, bId) {
|
|
6813
|
+
const a = tree.get(aId);
|
|
6814
|
+
const b = tree.get(bId);
|
|
6815
|
+
if (!a || !b || a.parentId !== b.parentId) return false;
|
|
6816
|
+
const parent = tree.get(a.parentId);
|
|
6817
|
+
if (!parent || parent.kind !== "split") return false;
|
|
6818
|
+
const i = parent.children.indexOf(aId);
|
|
6819
|
+
const j = parent.children.indexOf(bId);
|
|
6820
|
+
if (i < 0 || j < 0) return false;
|
|
6821
|
+
parent.children[i] = bId;
|
|
6822
|
+
parent.children[j] = aId;
|
|
6823
|
+
const size = parent.sizes[i];
|
|
6824
|
+
parent.sizes[i] = parent.sizes[j];
|
|
6825
|
+
parent.sizes[j] = size;
|
|
6826
|
+
return true;
|
|
6827
|
+
}
|
|
4145
6828
|
|
|
4146
6829
|
// src/tiling/tile_tab_menu.js
|
|
4147
6830
|
var PAGE_SIZE = 10;
|
|
@@ -4411,6 +7094,110 @@ function openTileTabMenu({
|
|
|
4411
7094
|
searchInput.focus();
|
|
4412
7095
|
return close;
|
|
4413
7096
|
}
|
|
7097
|
+
function openTileTabSwitcher({ x, y, tabs, activeIdx = 0, onPick }) {
|
|
7098
|
+
const list = Array.isArray(tabs) ? tabs : [];
|
|
7099
|
+
if (list.length === 0) return () => {
|
|
7100
|
+
};
|
|
7101
|
+
const overlay = document.createElement("div");
|
|
7102
|
+
overlay.className = "twm-tile-tabswitch-overlay";
|
|
7103
|
+
const rows = list.map((t, i) => `
|
|
7104
|
+
<li class="twm-tile-tabswitch__item${i === activeIdx ? " twm-tile-tabswitch__item--on" : ""}"
|
|
7105
|
+
data-idx="${i}" role="option" aria-selected="${i === activeIdx}">
|
|
7106
|
+
<span class="material-symbols-outlined twm-tile-tabswitch__check">${i === activeIdx ? "check" : ""}</span>
|
|
7107
|
+
<span class="twm-tile-tabswitch__label">${_esc7(t.title || t.kind || "Tab")}</span>
|
|
7108
|
+
</li>
|
|
7109
|
+
`).join("");
|
|
7110
|
+
overlay.innerHTML = `
|
|
7111
|
+
<div class="twm-tile-tabswitch" role="dialog" aria-label="Open tabs">
|
|
7112
|
+
<header class="twm-tile-tabswitch__head">
|
|
7113
|
+
<span class="material-symbols-outlined">tab</span>
|
|
7114
|
+
<span class="twm-tile-tabswitch__head-label">Open tabs</span>
|
|
7115
|
+
</header>
|
|
7116
|
+
<ul class="twm-tile-tabswitch__list" role="listbox">${rows}</ul>
|
|
7117
|
+
</div>
|
|
7118
|
+
`;
|
|
7119
|
+
document.body.appendChild(overlay);
|
|
7120
|
+
const panel = overlay.querySelector(".twm-tile-tabswitch");
|
|
7121
|
+
const W = 260;
|
|
7122
|
+
panel.style.position = "fixed";
|
|
7123
|
+
panel.style.left = `${Math.max(8, Math.min(window.innerWidth - W - 8, x))}px`;
|
|
7124
|
+
panel.style.bottom = `${Math.max(8, window.innerHeight - y + 4)}px`;
|
|
7125
|
+
panel.style.maxHeight = `${Math.max(120, y - 16)}px`;
|
|
7126
|
+
let alive = true;
|
|
7127
|
+
let cursor = Math.max(0, Math.min(list.length - 1, activeIdx));
|
|
7128
|
+
const close = () => {
|
|
7129
|
+
if (!alive) return;
|
|
7130
|
+
alive = false;
|
|
7131
|
+
document.removeEventListener("mousedown", onOutside, true);
|
|
7132
|
+
document.removeEventListener("keydown", onKey, true);
|
|
7133
|
+
overlay.remove();
|
|
7134
|
+
};
|
|
7135
|
+
const onOutside = (ev) => {
|
|
7136
|
+
if (!overlay.contains(ev.target)) close();
|
|
7137
|
+
};
|
|
7138
|
+
const pick = (idx) => {
|
|
7139
|
+
close();
|
|
7140
|
+
try {
|
|
7141
|
+
onPick?.(idx);
|
|
7142
|
+
} catch (err) {
|
|
7143
|
+
console.warn("[tile-tab-switch] pick failed", err);
|
|
7144
|
+
}
|
|
7145
|
+
};
|
|
7146
|
+
const paint = () => {
|
|
7147
|
+
overlay.querySelectorAll(".twm-tile-tabswitch__item").forEach((li) => {
|
|
7148
|
+
const on = Number(li.dataset.idx) === cursor;
|
|
7149
|
+
li.classList.toggle("twm-tile-tabswitch__item--cursor", on);
|
|
7150
|
+
if (on) li.scrollIntoView({ block: "nearest" });
|
|
7151
|
+
});
|
|
7152
|
+
};
|
|
7153
|
+
const onKey = (ev) => {
|
|
7154
|
+
if (!alive || ev.isComposing) return;
|
|
7155
|
+
switch (ev.key) {
|
|
7156
|
+
case "Escape":
|
|
7157
|
+
ev.preventDefault();
|
|
7158
|
+
close();
|
|
7159
|
+
return;
|
|
7160
|
+
case "ArrowDown":
|
|
7161
|
+
ev.preventDefault();
|
|
7162
|
+
cursor = Math.min(list.length - 1, cursor + 1);
|
|
7163
|
+
paint();
|
|
7164
|
+
return;
|
|
7165
|
+
case "ArrowUp":
|
|
7166
|
+
ev.preventDefault();
|
|
7167
|
+
cursor = Math.max(0, cursor - 1);
|
|
7168
|
+
paint();
|
|
7169
|
+
return;
|
|
7170
|
+
case "Home":
|
|
7171
|
+
ev.preventDefault();
|
|
7172
|
+
cursor = 0;
|
|
7173
|
+
paint();
|
|
7174
|
+
return;
|
|
7175
|
+
case "End":
|
|
7176
|
+
ev.preventDefault();
|
|
7177
|
+
cursor = list.length - 1;
|
|
7178
|
+
paint();
|
|
7179
|
+
return;
|
|
7180
|
+
case "Enter":
|
|
7181
|
+
ev.preventDefault();
|
|
7182
|
+
pick(cursor);
|
|
7183
|
+
return;
|
|
7184
|
+
}
|
|
7185
|
+
};
|
|
7186
|
+
overlay.querySelectorAll(".twm-tile-tabswitch__item").forEach((li) => {
|
|
7187
|
+
const idx = Number(li.dataset.idx);
|
|
7188
|
+
li.addEventListener("mousemove", () => {
|
|
7189
|
+
if (cursor !== idx) {
|
|
7190
|
+
cursor = idx;
|
|
7191
|
+
paint();
|
|
7192
|
+
}
|
|
7193
|
+
});
|
|
7194
|
+
li.addEventListener("click", () => pick(idx));
|
|
7195
|
+
});
|
|
7196
|
+
document.addEventListener("mousedown", onOutside, true);
|
|
7197
|
+
document.addEventListener("keydown", onKey, true);
|
|
7198
|
+
paint();
|
|
7199
|
+
return close;
|
|
7200
|
+
}
|
|
4414
7201
|
function _matchesShaped(shaped, query) {
|
|
4415
7202
|
if (!shaped) return false;
|
|
4416
7203
|
const id = String(shaped.id ?? "").toLowerCase();
|
|
@@ -4442,7 +7229,15 @@ async function createShell({
|
|
|
4442
7229
|
events = {},
|
|
4443
7230
|
rootCrumb = null,
|
|
4444
7231
|
palette: paletteCfg = {},
|
|
4445
|
-
|
|
7232
|
+
panels = null,
|
|
7233
|
+
snapPromotion = false,
|
|
7234
|
+
promoteInPlace = false,
|
|
7235
|
+
tabLayout = null,
|
|
7236
|
+
chrome = {},
|
|
7237
|
+
// An embedder that moved its sections out of the top bar — into an icon
|
|
7238
|
+
// rail, say — passes the selector its own buttons match, and F1..F8 keep
|
|
7239
|
+
// working. Omitted, the default top-bar selector applies.
|
|
7240
|
+
navSelector = null
|
|
4446
7241
|
} = {}) {
|
|
4447
7242
|
if (!root || typeof root.appendChild !== "function") {
|
|
4448
7243
|
throw new TypeError("createShell: `root` must be an element");
|
|
@@ -4473,6 +7268,10 @@ async function createShell({
|
|
|
4473
7268
|
host,
|
|
4474
7269
|
taxonomy,
|
|
4475
7270
|
events,
|
|
7271
|
+
panelDefaults: panels,
|
|
7272
|
+
snapPromotion,
|
|
7273
|
+
promoteInPlace,
|
|
7274
|
+
tabLayout,
|
|
4476
7275
|
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
4477
7276
|
// spreads it into every content factory, which is how the breadcrumb
|
|
4478
7277
|
// gets `taxonomy` + `rootCrumb` without a content factory knowing they
|
|
@@ -4499,7 +7298,7 @@ async function createShell({
|
|
|
4499
7298
|
catalog: entities,
|
|
4500
7299
|
...paletteCfg
|
|
4501
7300
|
});
|
|
4502
|
-
installKeymap({ wm, palette });
|
|
7301
|
+
const disposeKeymap = installKeymap({ wm, palette, navSelector });
|
|
4503
7302
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
4504
7303
|
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
4505
7304
|
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
@@ -4521,11 +7320,29 @@ async function createShell({
|
|
|
4521
7320
|
desktopsEl,
|
|
4522
7321
|
panelToggles: toggles
|
|
4523
7322
|
}),
|
|
7323
|
+
// A shell that can be built can be built TWICE — an embedder that
|
|
7324
|
+
// rebuilds on a context change (a different project, a different
|
|
7325
|
+
// workspace) does exactly that. Everything this function installs
|
|
7326
|
+
// outside `root` has to come off, or the second shell shares the page
|
|
7327
|
+
// with the first one's keyboard.
|
|
4524
7328
|
dispose: () => {
|
|
4525
7329
|
try {
|
|
4526
7330
|
eventBus?.off?.("wm:changed", syncChrome);
|
|
4527
7331
|
} catch {
|
|
4528
7332
|
}
|
|
7333
|
+
try {
|
|
7334
|
+
disposeKeymap?.();
|
|
7335
|
+
} catch {
|
|
7336
|
+
}
|
|
7337
|
+
try {
|
|
7338
|
+
palette?.close?.();
|
|
7339
|
+
} catch {
|
|
7340
|
+
}
|
|
7341
|
+
try {
|
|
7342
|
+
wm.renderer.destroy();
|
|
7343
|
+
} catch (err) {
|
|
7344
|
+
log.warn?.("renderer teardown", err);
|
|
7345
|
+
}
|
|
4529
7346
|
}
|
|
4530
7347
|
});
|
|
4531
7348
|
}
|
|
@@ -4565,8 +7382,7 @@ function syncTopNav(hostEl, wm) {
|
|
|
4565
7382
|
}
|
|
4566
7383
|
function mountPaletteButton(hostEl, palette) {
|
|
4567
7384
|
if (!hostEl) return null;
|
|
4568
|
-
|
|
4569
|
-
if (existing) return existing;
|
|
7385
|
+
hostEl.querySelector("#twm-palette-btn")?.remove();
|
|
4570
7386
|
const btn = document.createElement("button");
|
|
4571
7387
|
btn.id = "twm-palette-btn";
|
|
4572
7388
|
btn.className = "twm-panel-toggle-btn twm-has-tooltip";
|
|
@@ -4708,24 +7524,20 @@ function syncDesktopBar(el, wm) {
|
|
|
4708
7524
|
function _tileTabMenu(wm, leafId, x, y) {
|
|
4709
7525
|
const tree = wm.desktops.active().tree;
|
|
4710
7526
|
const leaf = tree.get(leafId);
|
|
4711
|
-
if (!leaf) return;
|
|
4712
|
-
const
|
|
4713
|
-
|
|
7527
|
+
if (!leaf || leaf.kind !== "leaf") return;
|
|
7528
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
7529
|
+
if (tabs.length === 0) return;
|
|
7530
|
+
openTileTabSwitcher({
|
|
4714
7531
|
x,
|
|
4715
7532
|
y,
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
onPick: (navKind, shaped) => {
|
|
4721
|
-
tree.appendLeafTab(leafId, {
|
|
4722
|
-
kind: navKind,
|
|
4723
|
-
props: { id: shaped.id, label: shaped.label }
|
|
4724
|
-
}, shaped.label || shaped.id);
|
|
7533
|
+
tabs,
|
|
7534
|
+
activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
|
|
7535
|
+
onPick: (idx) => {
|
|
7536
|
+
tree.setActiveLeafTab(leafId, idx);
|
|
4725
7537
|
tree.focus(leafId);
|
|
4726
7538
|
wm.renderer.render();
|
|
4727
7539
|
wm._persist?.();
|
|
4728
|
-
wm._notifyChange?.("tab-
|
|
7540
|
+
wm._notifyChange?.("tab-switch-from-menu");
|
|
4729
7541
|
}
|
|
4730
7542
|
});
|
|
4731
7543
|
}
|
|
@@ -4746,17 +7558,41 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
4746
7558
|
action: "open-tab",
|
|
4747
7559
|
disabled: isPanel || !leaf.content
|
|
4748
7560
|
},
|
|
7561
|
+
// TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
|
|
7562
|
+
// window INSIDE the application — the same glyph `ManagedWindow` uses
|
|
7563
|
+
// for itself — and an embedder that can also send content to a real
|
|
7564
|
+
// browser window keeps `open_in_new`, which is the universal "this
|
|
7565
|
+
// leaves the page". One glyph for both is how a user learns that the
|
|
7566
|
+
// two commands are the same command, and then loses a window looking
|
|
7567
|
+
// for it on the other screen.
|
|
4749
7568
|
{
|
|
4750
|
-
label: "Open in
|
|
4751
|
-
icon: "
|
|
7569
|
+
label: "Open a copy in a window",
|
|
7570
|
+
icon: "web_asset",
|
|
4752
7571
|
action: "open-window",
|
|
4753
7572
|
disabled: isPanel || !leaf.content
|
|
4754
7573
|
},
|
|
7574
|
+
// C20, THE OTHER HALF — and it was missing while the `close` half
|
|
7575
|
+
// below carried a paragraph explaining why it could not be.
|
|
7576
|
+
//
|
|
7577
|
+
// `_floatableLeaf` (`wm.js`) refuses to float content that declared
|
|
7578
|
+
// `chrome: { promote: false }`, and every door converges there — so
|
|
7579
|
+
// this row offered the verb, enabled, and returned null. The chrome's
|
|
7580
|
+
// own float BUTTON does not have the problem: C20 removes it from the
|
|
7581
|
+
// strip. That asymmetry is what hid this: the affordance the reader
|
|
7582
|
+
// checks is correct, and the menu one layer down is not.
|
|
7583
|
+
//
|
|
7584
|
+
// `=== false` EXACTLY, because that is the test the verb makes
|
|
7585
|
+
// (`wm.js`, `_floatableLeaf`: *"content that says nothing about
|
|
7586
|
+
// `promote` stays floatable"*). A falsy test here would grey the row
|
|
7587
|
+
// on every leaf whose content returned no `chrome` at all, which is
|
|
7588
|
+
// most of them — a menu disagreeing with its verb in the generous
|
|
7589
|
+
// direction is a dead control; in the mean direction it is a missing
|
|
7590
|
+
// feature, and this file has shipped one of each.
|
|
4755
7591
|
{
|
|
4756
|
-
label: "
|
|
4757
|
-
icon: "
|
|
7592
|
+
label: "Float this pane as a window",
|
|
7593
|
+
icon: "web_asset",
|
|
4758
7594
|
action: "promote",
|
|
4759
|
-
disabled: isPanel || !leaf.content
|
|
7595
|
+
disabled: isPanel || !leaf.content || wm.renderer?.leafChrome?.(leafId)?.promote === false
|
|
4760
7596
|
}
|
|
4761
7597
|
];
|
|
4762
7598
|
if (wm.desktops.desktops.length > 1 && !isPanel) {
|
|
@@ -4770,12 +7606,15 @@ function _tileContextMenu(wm, leafId, x, y) {
|
|
|
4770
7606
|
}
|
|
4771
7607
|
}
|
|
4772
7608
|
items.push({ separator: true });
|
|
7609
|
+
const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
|
|
7610
|
+
const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
|
|
4773
7611
|
items.push({
|
|
4774
7612
|
label: "Close tile",
|
|
4775
7613
|
icon: "close",
|
|
4776
7614
|
action: "close",
|
|
4777
7615
|
danger: true,
|
|
4778
|
-
disabled: isPanel
|
|
7616
|
+
disabled: isPanel || closeVetoed,
|
|
7617
|
+
title: closeVetoed ? closeVeto?.title || void 0 : void 0
|
|
4779
7618
|
});
|
|
4780
7619
|
showContextMenu(x, y, items, (action) => {
|
|
4781
7620
|
if (action === "split-h") wm.split("h");
|
|
@@ -4797,6 +7636,7 @@ export {
|
|
|
4797
7636
|
DesktopManager,
|
|
4798
7637
|
PLACEHOLDER_KIND,
|
|
4799
7638
|
PanelKeyRouter,
|
|
7639
|
+
TILE_TAB_MIME,
|
|
4800
7640
|
TileRenderer,
|
|
4801
7641
|
TileTree,
|
|
4802
7642
|
WindowManager,
|
|
@@ -4822,6 +7662,7 @@ export {
|
|
|
4822
7662
|
mountNavPanel,
|
|
4823
7663
|
mountTileBreadcrumb,
|
|
4824
7664
|
openTileTabMenu,
|
|
7665
|
+
openTileTabSwitcher,
|
|
4825
7666
|
registerPanelKeys,
|
|
4826
7667
|
saveDesktops,
|
|
4827
7668
|
uninstallPanelKeyRouter,
|