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