flexdesk 0.1.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/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
package/dist/wm.js
ADDED
|
@@ -0,0 +1,4830 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HelpModal,
|
|
3
|
+
openForm,
|
|
4
|
+
showContextMenu
|
|
5
|
+
} from "./chunk-DVU44T77.js";
|
|
6
|
+
import {
|
|
7
|
+
ManagedWindow
|
|
8
|
+
} from "./chunk-UCJ2WD4D.js";
|
|
9
|
+
import "./chunk-FL5KFNQH.js";
|
|
10
|
+
import "./chunk-JYWURG5T.js";
|
|
11
|
+
|
|
12
|
+
// src/tiling/command_palette.js
|
|
13
|
+
var ROOT_ID = "twm-cmdpal";
|
|
14
|
+
var STATIC_COMMANDS = [
|
|
15
|
+
{
|
|
16
|
+
kind: "command",
|
|
17
|
+
id: "shortcuts",
|
|
18
|
+
label: "Keyboard shortcuts",
|
|
19
|
+
hint: "Show all key bindings (?)",
|
|
20
|
+
icon: "keyboard",
|
|
21
|
+
action: () => HelpModal.open("keyboard-shortcuts")
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Search\u2026" }) {
|
|
25
|
+
if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
|
|
26
|
+
if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
|
|
27
|
+
let overlay = null;
|
|
28
|
+
let entities = [];
|
|
29
|
+
const isOpen = () => !!overlay;
|
|
30
|
+
const close = () => {
|
|
31
|
+
if (!overlay) return;
|
|
32
|
+
overlay.remove();
|
|
33
|
+
overlay = null;
|
|
34
|
+
};
|
|
35
|
+
const toggle = () => {
|
|
36
|
+
isOpen() ? close() : open();
|
|
37
|
+
};
|
|
38
|
+
const open = async () => {
|
|
39
|
+
if (overlay) return;
|
|
40
|
+
overlay = document.createElement("div");
|
|
41
|
+
overlay.id = ROOT_ID;
|
|
42
|
+
overlay.className = "twm-cmdpal-overlay";
|
|
43
|
+
overlay.innerHTML = _markup(taxonomy, placeholder);
|
|
44
|
+
document.body.appendChild(overlay);
|
|
45
|
+
overlay.addEventListener("click", (e) => {
|
|
46
|
+
if (e.target === overlay) close();
|
|
47
|
+
});
|
|
48
|
+
const input = overlay.querySelector(".twm-cmdpal__input");
|
|
49
|
+
const list = overlay.querySelector('[data-role="list"]');
|
|
50
|
+
const chipSlot = overlay.querySelector('[data-role="type-chip"]');
|
|
51
|
+
const chips = overlay.querySelectorAll("[data-shortcut]");
|
|
52
|
+
const toggles = overlay.querySelectorAll("[data-toggle]");
|
|
53
|
+
for (const btn of toggles) {
|
|
54
|
+
const which = btn.dataset.toggle;
|
|
55
|
+
btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
|
|
56
|
+
}
|
|
57
|
+
chips.forEach((btn) => btn.addEventListener("click", () => {
|
|
58
|
+
const kind = btn.dataset.shortcut;
|
|
59
|
+
wm.openInPrimary(kind);
|
|
60
|
+
close();
|
|
61
|
+
}));
|
|
62
|
+
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
|
63
|
+
const which = btn.dataset.toggle;
|
|
64
|
+
wm.togglePanel(which);
|
|
65
|
+
btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
|
|
66
|
+
}));
|
|
67
|
+
let filtered = [];
|
|
68
|
+
let active = 0;
|
|
69
|
+
let typeFilter = null;
|
|
70
|
+
let typeFilterLabel = null;
|
|
71
|
+
const renderChip = () => {
|
|
72
|
+
if (!typeFilter) {
|
|
73
|
+
chipSlot.innerHTML = "";
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const head = typeFilter[0];
|
|
77
|
+
const meta = taxonomy.meta(head);
|
|
78
|
+
const label = typeFilterLabel || meta?.label || head;
|
|
79
|
+
chipSlot.innerHTML = `
|
|
80
|
+
<span class="twm-cmdpal__type-chip" title="Filtering by ${_esc(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
|
|
81
|
+
<span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${meta?.icon || "arrow_right"}</span>
|
|
82
|
+
<span class="twm-cmdpal__type-chip-label">${_esc(label)}</span>
|
|
83
|
+
<button type="button" class="twm-cmdpal__type-chip-x"
|
|
84
|
+
data-action="clear-type"
|
|
85
|
+
aria-label="Clear type filter">\xD7</button>
|
|
86
|
+
</span>
|
|
87
|
+
`;
|
|
88
|
+
chipSlot.querySelector('[data-action="clear-type"]')?.addEventListener("click", (ev) => {
|
|
89
|
+
ev.preventDefault();
|
|
90
|
+
typeFilter = null;
|
|
91
|
+
typeFilterLabel = null;
|
|
92
|
+
renderChip();
|
|
93
|
+
input.focus();
|
|
94
|
+
refilter();
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
const render = () => {
|
|
98
|
+
list.innerHTML = filtered.slice(0, 200).map((e, i) => `
|
|
99
|
+
<div class="twm-cmdpal__item${i === active ? " twm-cmdpal__item--active" : ""}"
|
|
100
|
+
data-idx="${i}">
|
|
101
|
+
<span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || "arrow_right"}</span>
|
|
102
|
+
<span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
|
|
103
|
+
<span class="twm-cmdpal__kind">${e.kind}</span>
|
|
104
|
+
<span class="twm-cmdpal__hint">${_esc(e.hint || "")}</span>
|
|
105
|
+
</div>
|
|
106
|
+
`).join("") || "";
|
|
107
|
+
list.querySelectorAll("[data-idx]").forEach((el) => {
|
|
108
|
+
el.addEventListener("click", () => commit(Number(el.dataset.idx)));
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
const commit = (i) => {
|
|
112
|
+
const pick = filtered[i];
|
|
113
|
+
if (!pick) return;
|
|
114
|
+
close();
|
|
115
|
+
if (pick.action) {
|
|
116
|
+
try {
|
|
117
|
+
pick.action();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(err);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
|
|
124
|
+
};
|
|
125
|
+
const tryConsumePrefix = () => {
|
|
126
|
+
if (typeFilter) return false;
|
|
127
|
+
const m = input.value.match(/^([a-zA-Z][a-zA-Z0-9_-]*):/);
|
|
128
|
+
if (!m) return false;
|
|
129
|
+
const kinds = catalog.resolveTypePrefix(m[1]);
|
|
130
|
+
if (!kinds || kinds.length === 0) return false;
|
|
131
|
+
typeFilter = kinds;
|
|
132
|
+
typeFilterLabel = _titleCase(m[1]);
|
|
133
|
+
input.value = input.value.slice(m[0].length);
|
|
134
|
+
renderChip();
|
|
135
|
+
return true;
|
|
136
|
+
};
|
|
137
|
+
const refilter = () => {
|
|
138
|
+
tryConsumePrefix();
|
|
139
|
+
const q = input.value.trim().toLowerCase();
|
|
140
|
+
const filterSet = typeFilter ? new Set(typeFilter) : null;
|
|
141
|
+
const pool = filterSet ? entities.filter((e) => filterSet.has(e.kind)) : entities;
|
|
142
|
+
if (!q) {
|
|
143
|
+
filtered = typeFilter ? pool.slice().sort((a, b) => String(a.label || a.id).localeCompare(b.label || b.id)) : [];
|
|
144
|
+
} else {
|
|
145
|
+
filtered = _search(pool, q);
|
|
146
|
+
}
|
|
147
|
+
active = 0;
|
|
148
|
+
render();
|
|
149
|
+
};
|
|
150
|
+
input.addEventListener("input", refilter);
|
|
151
|
+
input.addEventListener("keydown", (e) => {
|
|
152
|
+
if (e.key === "ArrowDown") {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
active = Math.min(filtered.length - 1, active + 1);
|
|
155
|
+
render();
|
|
156
|
+
} else if (e.key === "ArrowUp") {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
active = Math.max(0, active - 1);
|
|
159
|
+
render();
|
|
160
|
+
} else if (e.key === "Enter") {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
commit(active);
|
|
163
|
+
} else if (e.key === "Escape") {
|
|
164
|
+
close();
|
|
165
|
+
} else if (e.key === "Backspace" && input.value === "" && typeFilter) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
typeFilter = null;
|
|
168
|
+
typeFilterLabel = null;
|
|
169
|
+
renderChip();
|
|
170
|
+
refilter();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
input.focus();
|
|
174
|
+
entities = _decorateIcons(STATIC_COMMANDS.slice(), taxonomy);
|
|
175
|
+
if (api) {
|
|
176
|
+
try {
|
|
177
|
+
entities = _decorateIcons(
|
|
178
|
+
[...STATIC_COMMANDS, ...await catalog.loadAll(api)],
|
|
179
|
+
taxonomy
|
|
180
|
+
);
|
|
181
|
+
if (input.value) refilter();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.warn("[cmdpal] entity load failed", err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
return { open, close, toggle, isOpen };
|
|
188
|
+
}
|
|
189
|
+
function _markup(taxonomy, placeholder) {
|
|
190
|
+
const chips = taxonomy.topNavEntries().map((k) => `
|
|
191
|
+
<button class="twm-chip" data-shortcut="${k.kind}">
|
|
192
|
+
<span class="material-symbols-outlined">${k.icon}</span>
|
|
193
|
+
${k.label}
|
|
194
|
+
</button>
|
|
195
|
+
`).join("");
|
|
196
|
+
return `
|
|
197
|
+
<div class="twm-cmdpal" role="dialog" aria-label="Command palette">
|
|
198
|
+
<div class="twm-cmdpal__inputrow">
|
|
199
|
+
<span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
|
|
200
|
+
<input type="text" class="twm-cmdpal__input"
|
|
201
|
+
placeholder="${_esc(placeholder)}"
|
|
202
|
+
autocomplete="off" />
|
|
203
|
+
</div>
|
|
204
|
+
<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>
|
|
219
|
+
<div class="twm-cmdpal__list" data-role="list"></div>
|
|
220
|
+
<div class="twm-cmdpal__footer">
|
|
221
|
+
<span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span>
|
|
222
|
+
<span><kbd>Enter</kbd> open</span>
|
|
223
|
+
<span><kbd>Esc</kbd> close</span>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
function _decorateIcons(rows, taxonomy) {
|
|
229
|
+
return rows.map((r) => {
|
|
230
|
+
if (r.icon) return r;
|
|
231
|
+
const meta = taxonomy.meta(r.kind);
|
|
232
|
+
return { ...r, icon: meta?.icon || "arrow_right" };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function _search(entities, q) {
|
|
236
|
+
const scored = [];
|
|
237
|
+
for (const e of entities) {
|
|
238
|
+
const id = String(e.id || "").toLowerCase();
|
|
239
|
+
const label = String(e.label || "").toLowerCase();
|
|
240
|
+
const hint = String(e.hint || "").toLowerCase();
|
|
241
|
+
let r;
|
|
242
|
+
if (id === q || label === q) r = 0;
|
|
243
|
+
else if (id.startsWith(q)) r = 1;
|
|
244
|
+
else if (label.startsWith(q)) r = 2;
|
|
245
|
+
else if (id.includes(q)) r = 3;
|
|
246
|
+
else if (label.includes(q)) r = 4;
|
|
247
|
+
else if (hint.includes(q)) r = 5;
|
|
248
|
+
else continue;
|
|
249
|
+
scored.push({ e, r });
|
|
250
|
+
}
|
|
251
|
+
scored.sort((a, b) => a.r - b.r || a.e.label.localeCompare(b.e.label));
|
|
252
|
+
return scored.map((x) => x.e);
|
|
253
|
+
}
|
|
254
|
+
function _esc(s) {
|
|
255
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
256
|
+
"&": "&",
|
|
257
|
+
"<": "<",
|
|
258
|
+
">": ">",
|
|
259
|
+
'"': """,
|
|
260
|
+
"'": "'"
|
|
261
|
+
})[c]);
|
|
262
|
+
}
|
|
263
|
+
function _titleCase(s) {
|
|
264
|
+
const str = String(s || "").replace(/[_-]+/g, " ").trim();
|
|
265
|
+
if (!str) return "";
|
|
266
|
+
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/tiling/content_registry.js
|
|
270
|
+
var PLACEHOLDER_KIND = "window-placeholder";
|
|
271
|
+
function windowPlaceholderFactory(hostEl, props, ctx) {
|
|
272
|
+
hostEl.innerHTML = `
|
|
273
|
+
<div class="twm-window-placeholder">
|
|
274
|
+
<span class="material-symbols-outlined twm-window-placeholder__icon">open_in_new</span>
|
|
275
|
+
<div class="twm-window-placeholder__title">${_esc2(props.originalTitle || props.originalKind || "content")}</div>
|
|
276
|
+
<div class="twm-window-placeholder__hint">is open in a managed window</div>
|
|
277
|
+
<button class="twm-window-placeholder__btn" data-action="bring-back">
|
|
278
|
+
Bring back to this tile
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
`;
|
|
282
|
+
hostEl.querySelector('[data-action="bring-back"]')?.addEventListener("click", () => {
|
|
283
|
+
ctx.wm?.bringBackWindow?.(props.windowId);
|
|
284
|
+
});
|
|
285
|
+
return { title: `${props.originalTitle || props.originalKind} (window)` };
|
|
286
|
+
}
|
|
287
|
+
function createContentRegistry(map = {}) {
|
|
288
|
+
if (!map || typeof map !== "object") {
|
|
289
|
+
throw new TypeError("createContentRegistry: `content` must be an object of kind -> factory");
|
|
290
|
+
}
|
|
291
|
+
for (const [kind, factory] of Object.entries(map)) {
|
|
292
|
+
if (typeof factory !== "function") {
|
|
293
|
+
throw new TypeError(
|
|
294
|
+
`createContentRegistry: factory for "${kind}" must be a function`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const factories = new Map(Object.entries(map));
|
|
299
|
+
if (!factories.has(PLACEHOLDER_KIND)) {
|
|
300
|
+
factories.set(PLACEHOLDER_KIND, windowPlaceholderFactory);
|
|
301
|
+
}
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
has: (kind) => factories.has(kind),
|
|
304
|
+
mount: (kind, hostEl, props, ctx) => {
|
|
305
|
+
const factory = factories.get(kind) || _placeholder(kind);
|
|
306
|
+
try {
|
|
307
|
+
return factory(hostEl, props || {}, ctx || {}) || {};
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error("[content_registry] mount failed for", kind, err);
|
|
310
|
+
hostEl.innerHTML = `<div class="twm-tile-error">Mount failed: ${err && err.message || err}</div>`;
|
|
311
|
+
return {};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function _placeholder(kind) {
|
|
317
|
+
return (hostEl) => {
|
|
318
|
+
hostEl.innerHTML = `
|
|
319
|
+
<div class="tile-placeholder">
|
|
320
|
+
<div class="tile-placeholder__title">${kind}</div>
|
|
321
|
+
<div class="tile-placeholder__hint">no factory registered yet</div>
|
|
322
|
+
</div>
|
|
323
|
+
`;
|
|
324
|
+
return { title: kind };
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function _esc2(s) {
|
|
328
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/tiling/tile_tree.js
|
|
332
|
+
var _idCounter = 1;
|
|
333
|
+
function _newId(prefix) {
|
|
334
|
+
return `${prefix}-${_idCounter++}`;
|
|
335
|
+
}
|
|
336
|
+
function _isPanel(leaf) {
|
|
337
|
+
return String(leaf?.content?.kind || "").startsWith("panel:");
|
|
338
|
+
}
|
|
339
|
+
function _syncActiveTab(leaf) {
|
|
340
|
+
if (!leaf || leaf.kind !== "leaf") return;
|
|
341
|
+
const tabs = leaf.tabs || [];
|
|
342
|
+
if (tabs.length === 0) {
|
|
343
|
+
leaf.content = null;
|
|
344
|
+
leaf.title = leaf.title || "";
|
|
345
|
+
leaf.activeTabIdx = 0;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (leaf.activeTabIdx == null || leaf.activeTabIdx < 0 || leaf.activeTabIdx >= tabs.length) {
|
|
349
|
+
leaf.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
350
|
+
}
|
|
351
|
+
const t = tabs[leaf.activeTabIdx];
|
|
352
|
+
leaf.content = { kind: t.kind, props: t.props || {} };
|
|
353
|
+
leaf.title = t.title || t.kind || "";
|
|
354
|
+
}
|
|
355
|
+
function makeLeaf({ content = null, title = "" } = {}) {
|
|
356
|
+
const leaf = {
|
|
357
|
+
id: _newId("leaf"),
|
|
358
|
+
kind: "leaf",
|
|
359
|
+
parentId: null,
|
|
360
|
+
// Tabs are the source of truth; content/title mirror tabs[active].
|
|
361
|
+
tabs: content ? [{ kind: content.kind, props: content.props || {}, title: title || content.kind || "" }] : [],
|
|
362
|
+
activeTabIdx: content ? 0 : 0,
|
|
363
|
+
content,
|
|
364
|
+
title,
|
|
365
|
+
// Per-top-nav-page tab archive. When a top-nav button switches
|
|
366
|
+
// this leaf to another page, the current `tabs` + `activeTabIdx`
|
|
367
|
+
// get stashed here under the LEAVING page's key, and the target
|
|
368
|
+
// page's archived list (if any) gets restored. This is what
|
|
369
|
+
// makes a tile remember its tab set when the user clicks away to
|
|
370
|
+
// another top-nav page and back. Keyed by top-nav kind — see the
|
|
371
|
+
// taxonomy's `topNavFor`.
|
|
372
|
+
pageTabs: {}
|
|
373
|
+
};
|
|
374
|
+
_syncActiveTab(leaf);
|
|
375
|
+
return leaf;
|
|
376
|
+
}
|
|
377
|
+
function makeSplit({ dir = "h", children = [], sizes = null } = {}) {
|
|
378
|
+
return {
|
|
379
|
+
id: _newId("split"),
|
|
380
|
+
kind: "split",
|
|
381
|
+
parentId: null,
|
|
382
|
+
dir,
|
|
383
|
+
children: children.slice(),
|
|
384
|
+
sizes: sizes ? sizes.slice() : children.map(() => 1)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
var TileTree = class _TileTree {
|
|
388
|
+
constructor() {
|
|
389
|
+
this.nodes = /* @__PURE__ */ new Map();
|
|
390
|
+
this.rootId = null;
|
|
391
|
+
this.focusedLeafId = null;
|
|
392
|
+
this.focusStack = [];
|
|
393
|
+
}
|
|
394
|
+
// ── Read helpers ────────────────────────────────────────────────
|
|
395
|
+
get(id) {
|
|
396
|
+
return this.nodes.get(id) || null;
|
|
397
|
+
}
|
|
398
|
+
root() {
|
|
399
|
+
return this.rootId ? this.get(this.rootId) : null;
|
|
400
|
+
}
|
|
401
|
+
focused() {
|
|
402
|
+
return this.focusedLeafId ? this.get(this.focusedLeafId) : null;
|
|
403
|
+
}
|
|
404
|
+
leaves() {
|
|
405
|
+
const out = [];
|
|
406
|
+
const walk = (id) => {
|
|
407
|
+
const n = this.get(id);
|
|
408
|
+
if (!n) return;
|
|
409
|
+
if (n.kind === "leaf") out.push(n);
|
|
410
|
+
else n.children.forEach(walk);
|
|
411
|
+
};
|
|
412
|
+
if (this.rootId) walk(this.rootId);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
// ── Construction ───────────────────────────────────────────────
|
|
416
|
+
setRoot(node) {
|
|
417
|
+
this._register(node);
|
|
418
|
+
this.rootId = node.id;
|
|
419
|
+
if (node.kind === "leaf") {
|
|
420
|
+
this.focus(node.id);
|
|
421
|
+
}
|
|
422
|
+
return node.id;
|
|
423
|
+
}
|
|
424
|
+
_register(node) {
|
|
425
|
+
this.nodes.set(node.id, node);
|
|
426
|
+
if (node.kind === "split") {
|
|
427
|
+
for (const cid of node.children) {
|
|
428
|
+
const c = this.get(cid);
|
|
429
|
+
if (c) c.parentId = node.id;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// ── Focus ───────────────────────────────────────────────────────
|
|
434
|
+
focus(leafId) {
|
|
435
|
+
if (!this.nodes.has(leafId)) return;
|
|
436
|
+
const node = this.get(leafId);
|
|
437
|
+
if (node.kind !== "leaf") return;
|
|
438
|
+
this.focusedLeafId = leafId;
|
|
439
|
+
this.focusStack = this.focusStack.filter((id) => id !== leafId);
|
|
440
|
+
this.focusStack.unshift(leafId);
|
|
441
|
+
}
|
|
442
|
+
/** The leaf where "open content" should land — top of focus stack,
|
|
443
|
+
* skipping panel:* tiles which are chrome, not content. */
|
|
444
|
+
primaryLeafId() {
|
|
445
|
+
for (const id of this.focusStack) {
|
|
446
|
+
const n = this.nodes.get(id);
|
|
447
|
+
if (n && n.kind === "leaf" && !_isPanel(n)) return id;
|
|
448
|
+
}
|
|
449
|
+
for (const leaf of this.leaves()) {
|
|
450
|
+
if (!_isPanel(leaf)) return leaf.id;
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
// ── Mutations ───────────────────────────────────────────────────
|
|
455
|
+
/** Replace the leaf's tab list with a single tab carrying `content`.
|
|
456
|
+
* Closes any existing tabs in that leaf — this is the "open in
|
|
457
|
+
* place" / "replace tile" path that the WM has always used. */
|
|
458
|
+
setLeafContent(leafId, content, title = "") {
|
|
459
|
+
const n = this.get(leafId);
|
|
460
|
+
if (!n || n.kind !== "leaf") return false;
|
|
461
|
+
if (content) {
|
|
462
|
+
n.tabs = [{
|
|
463
|
+
kind: content.kind,
|
|
464
|
+
props: content.props || {},
|
|
465
|
+
title: title || content.kind || ""
|
|
466
|
+
}];
|
|
467
|
+
n.activeTabIdx = 0;
|
|
468
|
+
} else {
|
|
469
|
+
n.tabs = [];
|
|
470
|
+
n.activeTabIdx = 0;
|
|
471
|
+
}
|
|
472
|
+
_syncActiveTab(n);
|
|
473
|
+
if (title) n.title = title;
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
/** Page-aware swap: archive the leaf's current tab list under its
|
|
477
|
+
* current top-nav key, then restore the target top-nav's archived
|
|
478
|
+
* list (or initialize one with the requested kind/props). Used
|
|
479
|
+
* by top-nav / palette navigation so each top-nav page keeps its
|
|
480
|
+
* own tab set across page switches.
|
|
481
|
+
*
|
|
482
|
+
* Parameters:
|
|
483
|
+
* leafId The primary leaf.
|
|
484
|
+
* target { kind, props, title } — what the user clicked.
|
|
485
|
+
* currentTopNav Top-nav of the leaf's current page (may be null).
|
|
486
|
+
* targetTopNav Top-nav of the target kind (must be set).
|
|
487
|
+
*
|
|
488
|
+
* When `currentTopNav === targetTopNav` (already on the right
|
|
489
|
+
* page), the swap is skipped and we either focus an existing
|
|
490
|
+
* matching tab or replace the active one. This is what makes
|
|
491
|
+
* clicking the top-nav button for the page you're already on
|
|
492
|
+
* feel like "Home for this page" without resetting tabs. */
|
|
493
|
+
swapToPage(leafId, target, currentTopNav, targetTopNav, opts = {}) {
|
|
494
|
+
const n = this.get(leafId);
|
|
495
|
+
if (!n || n.kind !== "leaf") return false;
|
|
496
|
+
if (!target || !target.kind) return false;
|
|
497
|
+
n.pageTabs = n.pageTabs || {};
|
|
498
|
+
const recordHistory = opts.recordHistory !== false;
|
|
499
|
+
const _pushPrev = (history, prev, next, extra) => {
|
|
500
|
+
if (!recordHistory || !prev || !prev.kind) return history;
|
|
501
|
+
const last = history[history.length - 1];
|
|
502
|
+
const sameAsPrev = last && last.kind === prev.kind && String(last.props?.id ?? "") === String(prev.props?.id ?? "");
|
|
503
|
+
const sameAsTarget = next && prev.kind === next.kind && String(prev.props?.id ?? "") === String(next.props?.id ?? "");
|
|
504
|
+
if (!sameAsPrev && !sameAsTarget) {
|
|
505
|
+
history.push({
|
|
506
|
+
kind: prev.kind,
|
|
507
|
+
props: prev.props || {},
|
|
508
|
+
title: prev.title || "",
|
|
509
|
+
...extra || {}
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return history;
|
|
513
|
+
};
|
|
514
|
+
const same = currentTopNav && currentTopNav === targetTopNav;
|
|
515
|
+
if (same) {
|
|
516
|
+
const tabs = n.tabs || [];
|
|
517
|
+
const matchIdx = tabs.findIndex((t) => t.kind === target.kind && String(t.props?.id ?? "") === String(target.props?.id ?? ""));
|
|
518
|
+
if (matchIdx >= 0) {
|
|
519
|
+
n.activeTabIdx = matchIdx;
|
|
520
|
+
} else if (tabs.length === 0) {
|
|
521
|
+
n.tabs = [{
|
|
522
|
+
kind: target.kind,
|
|
523
|
+
props: target.props || {},
|
|
524
|
+
title: target.title || target.kind,
|
|
525
|
+
history: []
|
|
526
|
+
}];
|
|
527
|
+
n.activeTabIdx = 0;
|
|
528
|
+
} else {
|
|
529
|
+
const prev = tabs[n.activeTabIdx];
|
|
530
|
+
const history = Array.isArray(prev?.history) ? prev.history : [];
|
|
531
|
+
_pushPrev(history, prev, target, null);
|
|
532
|
+
tabs[n.activeTabIdx] = {
|
|
533
|
+
kind: target.kind,
|
|
534
|
+
props: target.props || {},
|
|
535
|
+
title: target.title || target.kind,
|
|
536
|
+
history
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
_syncActiveTab(n);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
const srcTabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
543
|
+
const srcActive = srcTabs[n.activeTabIdx];
|
|
544
|
+
const sourceEntry = currentTopNav && srcActive?.kind ? {
|
|
545
|
+
kind: srcActive.kind,
|
|
546
|
+
props: srcActive.props || {},
|
|
547
|
+
title: srcActive.title || "",
|
|
548
|
+
topNav: currentTopNav
|
|
549
|
+
} : null;
|
|
550
|
+
if (currentTopNav && srcTabs.length > 0) {
|
|
551
|
+
const archived = srcTabs.filter((t) => !t.transient).map((t) => ({
|
|
552
|
+
kind: t.kind,
|
|
553
|
+
props: { ...t.props || {} },
|
|
554
|
+
title: t.title,
|
|
555
|
+
history: Array.isArray(t.history) ? t.history.slice() : []
|
|
556
|
+
}));
|
|
557
|
+
n.pageTabs[currentTopNav] = {
|
|
558
|
+
tabs: archived,
|
|
559
|
+
activeTabIdx: Math.max(0, Math.min(archived.length - 1, n.activeTabIdx || 0))
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const saved = n.pageTabs[targetTopNav];
|
|
563
|
+
if (saved && Array.isArray(saved.tabs) && saved.tabs.length > 0) {
|
|
564
|
+
n.tabs = saved.tabs.map((t) => ({
|
|
565
|
+
kind: t.kind,
|
|
566
|
+
props: { ...t.props || {} },
|
|
567
|
+
title: t.title,
|
|
568
|
+
history: Array.isArray(t.history) ? t.history.slice() : []
|
|
569
|
+
}));
|
|
570
|
+
n.activeTabIdx = Math.max(
|
|
571
|
+
0,
|
|
572
|
+
Math.min(n.tabs.length - 1, saved.activeTabIdx || 0)
|
|
573
|
+
);
|
|
574
|
+
const wantsTarget = target.props?.id != null || target.kind !== targetTopNav;
|
|
575
|
+
if (wantsTarget) {
|
|
576
|
+
const wantId = target.props?.id != null;
|
|
577
|
+
const matchIdx = n.tabs.findIndex((t) => t.kind === target.kind && (!wantId || String(t.props?.id ?? "") === String(target.props.id)));
|
|
578
|
+
if (matchIdx >= 0) {
|
|
579
|
+
n.activeTabIdx = matchIdx;
|
|
580
|
+
} else {
|
|
581
|
+
const history = [];
|
|
582
|
+
if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
|
|
583
|
+
n.tabs.push({
|
|
584
|
+
kind: target.kind,
|
|
585
|
+
props: target.props || {},
|
|
586
|
+
title: target.title || target.kind,
|
|
587
|
+
history
|
|
588
|
+
});
|
|
589
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
const history = [];
|
|
594
|
+
if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
|
|
595
|
+
n.tabs = [{
|
|
596
|
+
kind: target.kind,
|
|
597
|
+
props: target.props || {},
|
|
598
|
+
title: target.title || target.kind,
|
|
599
|
+
history
|
|
600
|
+
}];
|
|
601
|
+
n.activeTabIdx = 0;
|
|
602
|
+
}
|
|
603
|
+
_syncActiveTab(n);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
/** Update any tab whose (kind, props.id) matches the given pair to
|
|
607
|
+
* reflect a new label. Called from the WM in response to the
|
|
608
|
+
* embedder's entity-renamed bus event, so tab titles stay live as
|
|
609
|
+
* the underlying entity is renamed. */
|
|
610
|
+
renameMatchingTabs(kind, id, newLabel) {
|
|
611
|
+
if (!kind || !id || !newLabel) return false;
|
|
612
|
+
let touched = false;
|
|
613
|
+
const visit = (tabs) => {
|
|
614
|
+
if (!Array.isArray(tabs)) return;
|
|
615
|
+
for (const t of tabs) {
|
|
616
|
+
if (t.kind === kind && String(t.props?.id ?? "") === String(id)) {
|
|
617
|
+
t.title = String(newLabel);
|
|
618
|
+
if (t.props) t.props.label = String(newLabel);
|
|
619
|
+
touched = true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
for (const n of this.nodes.values()) {
|
|
624
|
+
if (n.kind !== "leaf") continue;
|
|
625
|
+
visit(n.tabs);
|
|
626
|
+
if (n.pageTabs) {
|
|
627
|
+
for (const arc of Object.values(n.pageTabs)) visit(arc?.tabs);
|
|
628
|
+
}
|
|
629
|
+
_syncActiveTab(n);
|
|
630
|
+
}
|
|
631
|
+
return touched;
|
|
632
|
+
}
|
|
633
|
+
/** Replace the active tab's content in place, preserving every
|
|
634
|
+
* other tab in the leaf. This is the "in-tile navigation" path:
|
|
635
|
+
* clicking a row on a landing page, walking back via Backspace,
|
|
636
|
+
* or clicking a breadcrumb segment should swap THIS tab's
|
|
637
|
+
* content without disturbing the user's other open tabs.
|
|
638
|
+
*
|
|
639
|
+
* By default the previous content is pushed onto the active
|
|
640
|
+
* tab's `history` stack so the user can Backspace through their
|
|
641
|
+
* own click-path (per-tab "browser back"). Callers that are
|
|
642
|
+
* themselves popping the history pass `{ recordHistory: false }`
|
|
643
|
+
* so we don't double-record. Same applies to programmatic loads
|
|
644
|
+
* that aren't user navigations.
|
|
645
|
+
*
|
|
646
|
+
* Falls back to `setLeafContent` for empty leaves so the WM
|
|
647
|
+
* never has to special-case the initial load. Returns true on
|
|
648
|
+
* success. */
|
|
649
|
+
replaceActiveTabContent(leafId, content, title = "", opts = {}) {
|
|
650
|
+
const n = this.get(leafId);
|
|
651
|
+
if (!n || n.kind !== "leaf") return false;
|
|
652
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
653
|
+
if (tabs.length === 0) {
|
|
654
|
+
return this.setLeafContent(leafId, content, title);
|
|
655
|
+
}
|
|
656
|
+
if (!content) return false;
|
|
657
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
658
|
+
const prev = tabs[idx];
|
|
659
|
+
const recordHistory = opts.recordHistory !== false;
|
|
660
|
+
const history = Array.isArray(prev?.history) ? prev.history : [];
|
|
661
|
+
if (recordHistory && prev && prev.kind) {
|
|
662
|
+
const last = history[history.length - 1];
|
|
663
|
+
const sameAsPrev = last && last.kind === prev.kind && String(last.props?.id ?? "") === String(prev.props?.id ?? "");
|
|
664
|
+
const sameAsTarget = prev.kind === content.kind && String(prev.props?.id ?? "") === String(content.props?.id ?? "");
|
|
665
|
+
if (!sameAsPrev && !sameAsTarget) {
|
|
666
|
+
history.push({
|
|
667
|
+
kind: prev.kind,
|
|
668
|
+
props: prev.props || {},
|
|
669
|
+
title: prev.title || ""
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
tabs[idx] = {
|
|
674
|
+
kind: content.kind,
|
|
675
|
+
props: content.props || {},
|
|
676
|
+
title: title || content.kind || "",
|
|
677
|
+
history
|
|
678
|
+
};
|
|
679
|
+
_syncActiveTab(n);
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
/** Merge `patch` into the active tab's `props`. Used when an
|
|
683
|
+
* in-tile editor surfaces metadata that should be captured in
|
|
684
|
+
* history snapshots (e.g. which sub-tab is open) so Backspace
|
|
685
|
+
* restores the user's view, not the default landing. Does not
|
|
686
|
+
* re-render or push history — pure metadata write. */
|
|
687
|
+
updateActiveTabProps(leafId, patch) {
|
|
688
|
+
const n = this.get(leafId);
|
|
689
|
+
if (!n || n.kind !== "leaf") return false;
|
|
690
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
691
|
+
if (tabs.length === 0) return false;
|
|
692
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
693
|
+
const cur = tabs[idx];
|
|
694
|
+
if (!cur) return false;
|
|
695
|
+
cur.props = { ...cur.props || {}, ...patch || {} };
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
/** Pop one entry off the active tab's history stack and return it,
|
|
699
|
+
* or null when empty. Doesn't mutate the tab beyond the stack —
|
|
700
|
+
* the caller is expected to apply the returned content via
|
|
701
|
+
* `replaceActiveTabContent(..., { recordHistory: false })`. */
|
|
702
|
+
popActiveTabHistory(leafId) {
|
|
703
|
+
const n = this.get(leafId);
|
|
704
|
+
if (!n || n.kind !== "leaf") return null;
|
|
705
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
706
|
+
if (tabs.length === 0) return null;
|
|
707
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
708
|
+
const cur = tabs[idx];
|
|
709
|
+
const history = Array.isArray(cur?.history) ? cur.history : [];
|
|
710
|
+
if (history.length === 0) return null;
|
|
711
|
+
return history.pop();
|
|
712
|
+
}
|
|
713
|
+
/** Inspect the active tab's history stack without popping. Used
|
|
714
|
+
* by the keymap to show / hide the back affordance and for
|
|
715
|
+
* diagnostics. */
|
|
716
|
+
activeTabHistory(leafId) {
|
|
717
|
+
const n = this.get(leafId);
|
|
718
|
+
if (!n || n.kind !== "leaf") return [];
|
|
719
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
720
|
+
if (tabs.length === 0) return [];
|
|
721
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
722
|
+
const cur = tabs[idx];
|
|
723
|
+
return Array.isArray(cur?.history) ? cur.history : [];
|
|
724
|
+
}
|
|
725
|
+
/** Append a new tab to a leaf and focus it. Returns the new tab's
|
|
726
|
+
* index, or -1 on failure. Panel tiles never grow tabs (they're
|
|
727
|
+
* chrome). */
|
|
728
|
+
appendLeafTab(leafId, content, title = "", opts = {}) {
|
|
729
|
+
const n = this.get(leafId);
|
|
730
|
+
if (!n || n.kind !== "leaf") return -1;
|
|
731
|
+
if (_isPanel(n)) return -1;
|
|
732
|
+
if (!content) return -1;
|
|
733
|
+
const tab = {
|
|
734
|
+
kind: content.kind,
|
|
735
|
+
props: content.props || {},
|
|
736
|
+
title: title || content.kind || "",
|
|
737
|
+
// Transient tabs (e.g. an add-row form) are not persisted /
|
|
738
|
+
// restored — see serialize() + the pageTabs archive.
|
|
739
|
+
...opts.transient ? { transient: true } : {}
|
|
740
|
+
};
|
|
741
|
+
n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
742
|
+
n.tabs.push(tab);
|
|
743
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
744
|
+
_syncActiveTab(n);
|
|
745
|
+
return n.activeTabIdx;
|
|
746
|
+
}
|
|
747
|
+
/** Switch the active tab on a leaf. No-op if `idx` is out of range. */
|
|
748
|
+
setActiveLeafTab(leafId, idx) {
|
|
749
|
+
const n = this.get(leafId);
|
|
750
|
+
if (!n || n.kind !== "leaf") return false;
|
|
751
|
+
if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return false;
|
|
752
|
+
n.activeTabIdx = idx;
|
|
753
|
+
_syncActiveTab(n);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
/** Remove a tab. If the active tab was removed, the previous one
|
|
757
|
+
* becomes active. If the last tab was removed, the leaf's content
|
|
758
|
+
* drops to null (and the WM should treat that as an empty tile —
|
|
759
|
+
* same as before tabs existed). Returns the (possibly clamped)
|
|
760
|
+
* active index after the operation, or -1 on failure. */
|
|
761
|
+
removeLeafTab(leafId, idx) {
|
|
762
|
+
const n = this.get(leafId);
|
|
763
|
+
if (!n || n.kind !== "leaf") return -1;
|
|
764
|
+
if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return -1;
|
|
765
|
+
n.tabs.splice(idx, 1);
|
|
766
|
+
if (n.tabs.length === 0) {
|
|
767
|
+
n.activeTabIdx = 0;
|
|
768
|
+
_syncActiveTab(n);
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
if (idx < n.activeTabIdx) n.activeTabIdx -= 1;
|
|
772
|
+
else if (idx === n.activeTabIdx) n.activeTabIdx = Math.max(0, idx - 1);
|
|
773
|
+
_syncActiveTab(n);
|
|
774
|
+
return n.activeTabIdx;
|
|
775
|
+
}
|
|
776
|
+
/** Bulk-close every tab in a leaf except the one at `keepIdx`.
|
|
777
|
+
* Used by the tab right-click "Close others" action. */
|
|
778
|
+
closeOtherTabs(leafId, keepIdx) {
|
|
779
|
+
const n = this.get(leafId);
|
|
780
|
+
if (!n || n.kind !== "leaf") return false;
|
|
781
|
+
const tabs = n.tabs || [];
|
|
782
|
+
if (keepIdx < 0 || keepIdx >= tabs.length) return false;
|
|
783
|
+
n.tabs = [tabs[keepIdx]];
|
|
784
|
+
n.activeTabIdx = 0;
|
|
785
|
+
_syncActiveTab(n);
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
/** Close every tab after `idx`. */
|
|
789
|
+
closeTabsAfter(leafId, idx) {
|
|
790
|
+
const n = this.get(leafId);
|
|
791
|
+
if (!n || n.kind !== "leaf") return false;
|
|
792
|
+
const tabs = n.tabs || [];
|
|
793
|
+
if (idx < 0 || idx >= tabs.length - 1) return false;
|
|
794
|
+
n.tabs = tabs.slice(0, idx + 1);
|
|
795
|
+
if (n.activeTabIdx > idx) n.activeTabIdx = idx;
|
|
796
|
+
_syncActiveTab(n);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
/** Close every tab before `idx`. */
|
|
800
|
+
closeTabsBefore(leafId, idx) {
|
|
801
|
+
const n = this.get(leafId);
|
|
802
|
+
if (!n || n.kind !== "leaf") return false;
|
|
803
|
+
const tabs = n.tabs || [];
|
|
804
|
+
if (idx <= 0 || idx >= tabs.length) return false;
|
|
805
|
+
n.tabs = tabs.slice(idx);
|
|
806
|
+
n.activeTabIdx = Math.max(0, n.activeTabIdx - idx);
|
|
807
|
+
_syncActiveTab(n);
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
/** Reorder a tab from `from` to `to` within the same leaf. */
|
|
811
|
+
moveLeafTab(leafId, from, to) {
|
|
812
|
+
const n = this.get(leafId);
|
|
813
|
+
if (!n || n.kind !== "leaf") return false;
|
|
814
|
+
const tabs = n.tabs || [];
|
|
815
|
+
if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return false;
|
|
816
|
+
const moved = tabs.splice(from, 1)[0];
|
|
817
|
+
tabs.splice(to, 0, moved);
|
|
818
|
+
if (n.activeTabIdx === from) n.activeTabIdx = to;
|
|
819
|
+
else if (from < n.activeTabIdx && to >= n.activeTabIdx) n.activeTabIdx -= 1;
|
|
820
|
+
else if (from > n.activeTabIdx && to <= n.activeTabIdx) n.activeTabIdx += 1;
|
|
821
|
+
_syncActiveTab(n);
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Split a leaf in the given direction; existing content stays in the
|
|
826
|
+
* original leaf, a new empty leaf is added next to it. Returns the
|
|
827
|
+
* new leaf id, or null on failure.
|
|
828
|
+
*/
|
|
829
|
+
split(leafId, dir) {
|
|
830
|
+
const leaf = this.get(leafId);
|
|
831
|
+
if (!leaf || leaf.kind !== "leaf") return null;
|
|
832
|
+
const newLeaf = makeLeaf();
|
|
833
|
+
if (!leaf.parentId) {
|
|
834
|
+
const wrap2 = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
|
|
835
|
+
this._register(newLeaf);
|
|
836
|
+
this._register(wrap2);
|
|
837
|
+
this.rootId = wrap2.id;
|
|
838
|
+
leaf.parentId = wrap2.id;
|
|
839
|
+
newLeaf.parentId = wrap2.id;
|
|
840
|
+
this.focus(newLeaf.id);
|
|
841
|
+
return newLeaf.id;
|
|
842
|
+
}
|
|
843
|
+
const parent = this.get(leaf.parentId);
|
|
844
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
845
|
+
if (idx < 0) return null;
|
|
846
|
+
if (parent.dir === dir) {
|
|
847
|
+
parent.children.splice(idx + 1, 0, newLeaf.id);
|
|
848
|
+
const avg = parent.sizes.reduce((a, b) => a + b, 0) / parent.children.length;
|
|
849
|
+
parent.sizes.splice(idx + 1, 0, avg);
|
|
850
|
+
newLeaf.parentId = parent.id;
|
|
851
|
+
this._register(newLeaf);
|
|
852
|
+
this.focus(newLeaf.id);
|
|
853
|
+
return newLeaf.id;
|
|
854
|
+
}
|
|
855
|
+
const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
|
|
856
|
+
wrap.parentId = parent.id;
|
|
857
|
+
parent.children[idx] = wrap.id;
|
|
858
|
+
leaf.parentId = wrap.id;
|
|
859
|
+
newLeaf.parentId = wrap.id;
|
|
860
|
+
this._register(newLeaf);
|
|
861
|
+
this._register(wrap);
|
|
862
|
+
this.focus(newLeaf.id);
|
|
863
|
+
return newLeaf.id;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Close a leaf, collapsing any single-child splits that result. If
|
|
867
|
+
* the last leaf is closed, leaves an empty tree (no root) — caller
|
|
868
|
+
* is expected to repopulate.
|
|
869
|
+
*/
|
|
870
|
+
close(leafId) {
|
|
871
|
+
const leaf = this.get(leafId);
|
|
872
|
+
if (!leaf || leaf.kind !== "leaf") return false;
|
|
873
|
+
if (!leaf.parentId) {
|
|
874
|
+
this.nodes.clear();
|
|
875
|
+
this.rootId = null;
|
|
876
|
+
this.focusedLeafId = null;
|
|
877
|
+
this.focusStack = [];
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
const parent = this.get(leaf.parentId);
|
|
881
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
882
|
+
parent.children.splice(idx, 1);
|
|
883
|
+
parent.sizes.splice(idx, 1);
|
|
884
|
+
this.nodes.delete(leaf.id);
|
|
885
|
+
this.focusStack = this.focusStack.filter((id) => id !== leaf.id);
|
|
886
|
+
this._collapse(parent);
|
|
887
|
+
if (this.focusedLeafId === leaf.id) {
|
|
888
|
+
const remaining = this.leaves();
|
|
889
|
+
this.focusedLeafId = remaining.length ? remaining[0].id : null;
|
|
890
|
+
if (this.focusedLeafId) this.focus(this.focusedLeafId);
|
|
891
|
+
}
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
_collapse(node) {
|
|
895
|
+
if (!node || node.kind !== "split") return;
|
|
896
|
+
if (node.children.length > 1) return;
|
|
897
|
+
if (node.children.length === 0) {
|
|
898
|
+
if (!node.parentId) {
|
|
899
|
+
this.nodes.delete(node.id);
|
|
900
|
+
this.rootId = null;
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const gp = this.get(node.parentId);
|
|
904
|
+
const idx = gp.children.indexOf(node.id);
|
|
905
|
+
gp.children.splice(idx, 1);
|
|
906
|
+
gp.sizes.splice(idx, 1);
|
|
907
|
+
this.nodes.delete(node.id);
|
|
908
|
+
this._collapse(gp);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const only = this.get(node.children[0]);
|
|
912
|
+
if (!node.parentId) {
|
|
913
|
+
this.rootId = only.id;
|
|
914
|
+
only.parentId = null;
|
|
915
|
+
} else {
|
|
916
|
+
const gp = this.get(node.parentId);
|
|
917
|
+
const idx = gp.children.indexOf(node.id);
|
|
918
|
+
gp.children[idx] = only.id;
|
|
919
|
+
only.parentId = gp.id;
|
|
920
|
+
}
|
|
921
|
+
this.nodes.delete(node.id);
|
|
922
|
+
}
|
|
923
|
+
// ── Directional focus ──────────────────────────────────────────
|
|
924
|
+
/**
|
|
925
|
+
* Move focus from current leaf in the given direction. Walks up the
|
|
926
|
+
* tree looking for an ancestor split whose direction matches the
|
|
927
|
+
* move (h for left/right, v for up/down) and which has a sibling on
|
|
928
|
+
* the requested side; then descends along the matching edge.
|
|
929
|
+
*/
|
|
930
|
+
focusDir(dir) {
|
|
931
|
+
const cur = this.focused();
|
|
932
|
+
if (!cur) return false;
|
|
933
|
+
const wantDir = dir === "left" || dir === "right" ? "h" : "v";
|
|
934
|
+
const forward = dir === "right" || dir === "down";
|
|
935
|
+
let child = cur;
|
|
936
|
+
let parent = child.parentId ? this.get(child.parentId) : null;
|
|
937
|
+
while (parent) {
|
|
938
|
+
if (parent.dir === wantDir) {
|
|
939
|
+
const idx = parent.children.indexOf(child.id);
|
|
940
|
+
const sibIdx = forward ? idx + 1 : idx - 1;
|
|
941
|
+
if (sibIdx >= 0 && sibIdx < parent.children.length) {
|
|
942
|
+
const target = this._descendEdge(
|
|
943
|
+
parent.children[sibIdx],
|
|
944
|
+
wantDir,
|
|
945
|
+
!forward
|
|
946
|
+
);
|
|
947
|
+
if (target) {
|
|
948
|
+
this.focus(target);
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
child = parent;
|
|
954
|
+
parent = child.parentId ? this.get(child.parentId) : null;
|
|
955
|
+
}
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Walk down to a leaf. For the matching direction we take the
|
|
960
|
+
* `edge` end (first/last). For the orthogonal direction we just
|
|
961
|
+
* take the first child.
|
|
962
|
+
*/
|
|
963
|
+
_descendEdge(nodeId, matchDir, takeFirst) {
|
|
964
|
+
let n = this.get(nodeId);
|
|
965
|
+
while (n && n.kind !== "leaf") {
|
|
966
|
+
const idx = n.dir === matchDir ? takeFirst ? 0 : n.children.length - 1 : 0;
|
|
967
|
+
n = this.get(n.children[idx]);
|
|
968
|
+
}
|
|
969
|
+
return n ? n.id : null;
|
|
970
|
+
}
|
|
971
|
+
// ── Move tile ──────────────────────────────────────────────────
|
|
972
|
+
/**
|
|
973
|
+
* Move the focused leaf one position in the given direction. Simple
|
|
974
|
+
* version: swap with the immediate sibling in the matching parent;
|
|
975
|
+
* if no such sibling, no-op (deep moves come later if needed).
|
|
976
|
+
*/
|
|
977
|
+
moveDir(dir) {
|
|
978
|
+
const cur = this.focused();
|
|
979
|
+
if (!cur || !cur.parentId) return false;
|
|
980
|
+
const wantDir = dir === "left" || dir === "right" ? "h" : "v";
|
|
981
|
+
const forward = dir === "right" || dir === "down";
|
|
982
|
+
let child = cur;
|
|
983
|
+
let parent = this.get(child.parentId);
|
|
984
|
+
while (parent && parent.dir !== wantDir) {
|
|
985
|
+
child = parent;
|
|
986
|
+
parent = child.parentId ? this.get(child.parentId) : null;
|
|
987
|
+
}
|
|
988
|
+
if (!parent) return false;
|
|
989
|
+
const idx = parent.children.indexOf(child.id);
|
|
990
|
+
const swap = forward ? idx + 1 : idx - 1;
|
|
991
|
+
if (swap < 0 || swap >= parent.children.length) return false;
|
|
992
|
+
[parent.children[idx], parent.children[swap]] = [parent.children[swap], parent.children[idx]];
|
|
993
|
+
[parent.sizes[idx], parent.sizes[swap]] = [parent.sizes[swap], parent.sizes[idx]];
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
// ── Resize ──────────────────────────────────────────────────────
|
|
997
|
+
setSplitSizes(splitId, sizes) {
|
|
998
|
+
const n = this.get(splitId);
|
|
999
|
+
if (!n || n.kind !== "split") return false;
|
|
1000
|
+
if (sizes.length !== n.children.length) return false;
|
|
1001
|
+
n.sizes = sizes.slice();
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
// ── Serialization (for desktops persistence) ──────────────────
|
|
1005
|
+
serialize() {
|
|
1006
|
+
if (!this.rootId) return null;
|
|
1007
|
+
const dump = (id) => {
|
|
1008
|
+
const n = this.get(id);
|
|
1009
|
+
if (n.kind === "leaf") {
|
|
1010
|
+
const liveTabs = (Array.isArray(n.tabs) ? n.tabs : []).filter((t) => !t.transient).map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
|
|
1011
|
+
return {
|
|
1012
|
+
kind: "leaf",
|
|
1013
|
+
title: n.title,
|
|
1014
|
+
content: n.content,
|
|
1015
|
+
tabs: liveTabs,
|
|
1016
|
+
activeTabIdx: Math.max(0, Math.min(liveTabs.length - 1, n.activeTabIdx || 0)),
|
|
1017
|
+
// Page-archive: per-top-nav-kind saved tab lists so
|
|
1018
|
+
// switching to a previously-visited page restores
|
|
1019
|
+
// its tabs across reloads.
|
|
1020
|
+
pageTabs: n.pageTabs ? Object.fromEntries(
|
|
1021
|
+
Object.entries(n.pageTabs).map(([k, v]) => {
|
|
1022
|
+
const pt = (v.tabs || []).filter((t) => !t.transient).map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
|
|
1023
|
+
return [k, {
|
|
1024
|
+
tabs: pt,
|
|
1025
|
+
activeTabIdx: Math.max(0, Math.min(pt.length - 1, v.activeTabIdx || 0))
|
|
1026
|
+
}];
|
|
1027
|
+
})
|
|
1028
|
+
) : {}
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
kind: "split",
|
|
1033
|
+
dir: n.dir,
|
|
1034
|
+
sizes: n.sizes.slice(),
|
|
1035
|
+
children: n.children.map(dump)
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
return {
|
|
1039
|
+
root: dump(this.rootId),
|
|
1040
|
+
focusedTitle: this.focused()?.title || null
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
static deserialize(blob) {
|
|
1044
|
+
const t = new _TileTree();
|
|
1045
|
+
if (!blob || !blob.root) return t;
|
|
1046
|
+
const build = (raw) => {
|
|
1047
|
+
if (raw.kind === "leaf") {
|
|
1048
|
+
const leaf = makeLeaf({ content: raw.content, title: raw.title });
|
|
1049
|
+
if (Array.isArray(raw.tabs) && raw.tabs.length > 0) {
|
|
1050
|
+
leaf.tabs = raw.tabs.map((tab) => ({
|
|
1051
|
+
kind: tab.kind,
|
|
1052
|
+
props: tab.props || {},
|
|
1053
|
+
title: tab.title || tab.kind || ""
|
|
1054
|
+
}));
|
|
1055
|
+
leaf.activeTabIdx = Math.max(
|
|
1056
|
+
0,
|
|
1057
|
+
Math.min(leaf.tabs.length - 1, raw.activeTabIdx || 0)
|
|
1058
|
+
);
|
|
1059
|
+
_syncActiveTab(leaf);
|
|
1060
|
+
}
|
|
1061
|
+
if (raw.pageTabs && typeof raw.pageTabs === "object") {
|
|
1062
|
+
leaf.pageTabs = {};
|
|
1063
|
+
for (const [k, v] of Object.entries(raw.pageTabs)) {
|
|
1064
|
+
if (!v || !Array.isArray(v.tabs)) continue;
|
|
1065
|
+
leaf.pageTabs[k] = {
|
|
1066
|
+
tabs: v.tabs.map((t2) => ({
|
|
1067
|
+
kind: t2.kind,
|
|
1068
|
+
props: t2.props || {},
|
|
1069
|
+
title: t2.title || t2.kind || ""
|
|
1070
|
+
})),
|
|
1071
|
+
activeTabIdx: v.activeTabIdx || 0
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return leaf;
|
|
1076
|
+
}
|
|
1077
|
+
const children = raw.children.map(build);
|
|
1078
|
+
const split = makeSplit({
|
|
1079
|
+
dir: raw.dir,
|
|
1080
|
+
children: children.map((c) => c.id),
|
|
1081
|
+
sizes: raw.sizes
|
|
1082
|
+
});
|
|
1083
|
+
children.forEach((c) => t._register(c));
|
|
1084
|
+
return split;
|
|
1085
|
+
};
|
|
1086
|
+
const root = build(blob.root);
|
|
1087
|
+
t.setRoot(root);
|
|
1088
|
+
return t;
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// src/tiling/desktops.js
|
|
1093
|
+
var DEFAULT_PANEL_STATE = {
|
|
1094
|
+
left: true,
|
|
1095
|
+
right: true,
|
|
1096
|
+
bottom: true
|
|
1097
|
+
};
|
|
1098
|
+
function _makeDesktop(label, seed) {
|
|
1099
|
+
const tree = new TileTree();
|
|
1100
|
+
tree.setRoot(makeLeaf(seed()));
|
|
1101
|
+
return {
|
|
1102
|
+
id: `desk-${Math.random().toString(36).slice(2, 8)}`,
|
|
1103
|
+
label,
|
|
1104
|
+
tree,
|
|
1105
|
+
windows: [],
|
|
1106
|
+
// boot default: left + right + bottom all open. Names are
|
|
1107
|
+
// assigned by wm._canonicalize via PANEL_TITLES.
|
|
1108
|
+
panels: { ...DEFAULT_PANEL_STATE }
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
var DesktopManager = class _DesktopManager {
|
|
1112
|
+
/** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
|
|
1113
|
+
constructor({ seed } = {}) {
|
|
1114
|
+
if (typeof seed !== "function") {
|
|
1115
|
+
throw new Error("DesktopManager: a `seed` function is required (the taxonomy root leaf)");
|
|
1116
|
+
}
|
|
1117
|
+
this.seed = seed;
|
|
1118
|
+
this.desktops = [_makeDesktop("1", seed)];
|
|
1119
|
+
this.activeIdx = 0;
|
|
1120
|
+
}
|
|
1121
|
+
active() {
|
|
1122
|
+
return this.desktops[this.activeIdx];
|
|
1123
|
+
}
|
|
1124
|
+
switchTo(idx) {
|
|
1125
|
+
if (idx < 0 || idx >= this.desktops.length) return false;
|
|
1126
|
+
if (idx === this.activeIdx) return false;
|
|
1127
|
+
this.activeIdx = idx;
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
ensureCount(n) {
|
|
1131
|
+
while (this.desktops.length < n) {
|
|
1132
|
+
this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
addDesktop(label = null) {
|
|
1136
|
+
const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
|
|
1137
|
+
this.desktops.push(d);
|
|
1138
|
+
return d;
|
|
1139
|
+
}
|
|
1140
|
+
serialize() {
|
|
1141
|
+
return {
|
|
1142
|
+
activeIdx: this.activeIdx,
|
|
1143
|
+
desktops: this.desktops.map((d) => ({
|
|
1144
|
+
id: d.id,
|
|
1145
|
+
label: d.label,
|
|
1146
|
+
tree: d.tree.serialize(),
|
|
1147
|
+
panels: { ...d.panels }
|
|
1148
|
+
// windows serialized at WM level since we don't own
|
|
1149
|
+
// managed-window state in this module.
|
|
1150
|
+
}))
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
static deserialize(blob, { seed } = {}) {
|
|
1154
|
+
const m = new _DesktopManager({ seed });
|
|
1155
|
+
if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
|
|
1156
|
+
m.desktops = blob.desktops.map((raw) => ({
|
|
1157
|
+
id: raw.id || `desk-${Math.random().toString(36).slice(2, 8)}`,
|
|
1158
|
+
label: raw.label || "?",
|
|
1159
|
+
tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
|
|
1160
|
+
windows: [],
|
|
1161
|
+
panels: { ...DEFAULT_PANEL_STATE, ...raw.panels || {} }
|
|
1162
|
+
}));
|
|
1163
|
+
for (const d of m.desktops) {
|
|
1164
|
+
if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
|
|
1165
|
+
}
|
|
1166
|
+
m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
|
|
1167
|
+
return m;
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
var DESKTOPS_KEY = "desktops";
|
|
1171
|
+
async function loadDesktops(state) {
|
|
1172
|
+
if (!state) return null;
|
|
1173
|
+
try {
|
|
1174
|
+
return await state.read(DESKTOPS_KEY);
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
console.warn("[desktops] load failed", err);
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
async function saveDesktops(state, blob) {
|
|
1181
|
+
if (!state) return false;
|
|
1182
|
+
try {
|
|
1183
|
+
await state.write(DESKTOPS_KEY, blob);
|
|
1184
|
+
return true;
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.warn("[desktops] save failed", err);
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// src/tiling/entity_sources.js
|
|
1192
|
+
function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
|
|
1193
|
+
if (!Array.isArray(sources)) {
|
|
1194
|
+
throw new TypeError("createEntityCatalog: `sources` must be an array");
|
|
1195
|
+
}
|
|
1196
|
+
for (const s of sources) {
|
|
1197
|
+
if (!s?.navKind || typeof s.list !== "function" || typeof s.shape !== "function") {
|
|
1198
|
+
throw new TypeError(
|
|
1199
|
+
`createEntityCatalog: source '${s?.navKind ?? "?"}' needs navKind + list() + shape()`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (aggregate != null && typeof aggregate !== "function") {
|
|
1204
|
+
throw new TypeError("createEntityCatalog: `aggregate` must be a function when present");
|
|
1205
|
+
}
|
|
1206
|
+
const _sources = Object.freeze(sources.map((s) => Object.freeze({ ...s })));
|
|
1207
|
+
const _byKind = new Map(_sources.map((s) => [s.navKind, s]));
|
|
1208
|
+
const _aliases = Object.freeze({ ...aliases });
|
|
1209
|
+
const get = (navKind) => _byKind.get(navKind) || null;
|
|
1210
|
+
const _extract = (src, raw) => {
|
|
1211
|
+
const list = src.pluck ? src.pluck(raw) : Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : [];
|
|
1212
|
+
return Array.isArray(list) ? list : [];
|
|
1213
|
+
};
|
|
1214
|
+
const shapeRows = (navKind, rows) => {
|
|
1215
|
+
const src = _byKind.get(navKind);
|
|
1216
|
+
if (!src || !Array.isArray(rows)) return [];
|
|
1217
|
+
const out = [];
|
|
1218
|
+
for (const row of rows) {
|
|
1219
|
+
let shaped;
|
|
1220
|
+
try {
|
|
1221
|
+
shaped = src.shape(row);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn("[entity-catalog]", navKind, "shape failed", err, row);
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (!shaped || !shaped.id) continue;
|
|
1227
|
+
out.push(shaped);
|
|
1228
|
+
}
|
|
1229
|
+
return out;
|
|
1230
|
+
};
|
|
1231
|
+
const loadOne = async (navKind, api) => {
|
|
1232
|
+
const src = _byKind.get(navKind);
|
|
1233
|
+
if (!src || !api) return [];
|
|
1234
|
+
try {
|
|
1235
|
+
return _extract(src, await src.list(api));
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
console.warn("[entity-catalog]", navKind, "fetch failed", err);
|
|
1238
|
+
return [];
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
const loadGrouped = async (api) => {
|
|
1242
|
+
const out = {};
|
|
1243
|
+
for (const s of _sources) out[s.navKind] = [];
|
|
1244
|
+
if (!api) return out;
|
|
1245
|
+
let bulk = null;
|
|
1246
|
+
if (aggregate) {
|
|
1247
|
+
try {
|
|
1248
|
+
bulk = await aggregate(api);
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
console.warn("[entity-catalog] aggregate failed, falling back to fan-out", err);
|
|
1251
|
+
}
|
|
1252
|
+
if (bulk && typeof bulk !== "object") bulk = null;
|
|
1253
|
+
}
|
|
1254
|
+
const missing = [];
|
|
1255
|
+
for (const src of _sources) {
|
|
1256
|
+
const raw = bulk ? bulk[src.navKind] : void 0;
|
|
1257
|
+
if (raw == null) {
|
|
1258
|
+
missing.push(src);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
out[src.navKind] = _extract(src, raw);
|
|
1262
|
+
}
|
|
1263
|
+
if (missing.length === 0) return out;
|
|
1264
|
+
await Promise.all(missing.map(async (src) => {
|
|
1265
|
+
out[src.navKind] = await loadOne(src.navKind, api);
|
|
1266
|
+
}));
|
|
1267
|
+
return out;
|
|
1268
|
+
};
|
|
1269
|
+
const loadAll = async (api) => {
|
|
1270
|
+
const grouped = await loadGrouped(api);
|
|
1271
|
+
const out = [];
|
|
1272
|
+
for (const src of _sources) {
|
|
1273
|
+
for (const shaped of shapeRows(src.navKind, grouped[src.navKind] || [])) {
|
|
1274
|
+
out.push({ kind: src.navKind, ...shaped });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
return out;
|
|
1278
|
+
};
|
|
1279
|
+
const resolveTypePrefix = (prefix) => {
|
|
1280
|
+
const p = String(prefix || "").toLowerCase().trim();
|
|
1281
|
+
if (!p) return null;
|
|
1282
|
+
if (_aliases[p]) return _aliases[p];
|
|
1283
|
+
for (const src of _sources) {
|
|
1284
|
+
if (src.navKind === p) return [src.navKind];
|
|
1285
|
+
if (src.navKind.replace(/-/g, "_") === p) return [src.navKind];
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
};
|
|
1289
|
+
return Object.freeze({
|
|
1290
|
+
sources: _sources,
|
|
1291
|
+
get,
|
|
1292
|
+
loadOne,
|
|
1293
|
+
loadGrouped,
|
|
1294
|
+
loadAll,
|
|
1295
|
+
shapeRows,
|
|
1296
|
+
resolveTypePrefix
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// src/tiling/keymap.js
|
|
1301
|
+
function installKeymap({ wm, palette }) {
|
|
1302
|
+
document.addEventListener("keydown", (e) => {
|
|
1303
|
+
const inField = e.target?.closest?.(
|
|
1304
|
+
'input, textarea, select, [contenteditable="true"]'
|
|
1305
|
+
);
|
|
1306
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.shiftKey) {
|
|
1307
|
+
e.preventDefault();
|
|
1308
|
+
palette.toggle();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === "Tab") {
|
|
1312
|
+
e.preventDefault();
|
|
1313
|
+
wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (e.key === "Escape") {
|
|
1317
|
+
if (palette.isOpen()) {
|
|
1318
|
+
palette.close();
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
if (e.key === "Backspace" && !inField && !e.metaKey && !e.ctrlKey) {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
wm.navigateBack?.();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
|
|
1328
|
+
if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
1329
|
+
const btns = document.querySelectorAll(
|
|
1330
|
+
".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
|
|
1331
|
+
);
|
|
1332
|
+
const idx = Number(fMatch[1]) - 1;
|
|
1333
|
+
if (idx < btns.length) {
|
|
1334
|
+
e.preventDefault();
|
|
1335
|
+
btns[idx].click();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
1340
|
+
const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
|
|
1341
|
+
if (tabs.length) {
|
|
1342
|
+
const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
|
|
1343
|
+
const dir = e.key === "ArrowRight" ? 1 : -1;
|
|
1344
|
+
const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
|
|
1345
|
+
e.preventDefault();
|
|
1346
|
+
tabs[next].click();
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
1351
|
+
e.preventDefault();
|
|
1352
|
+
wm.cycleDesktop(e.key === "ArrowRight" ? 1 : -1);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (!e.altKey) return;
|
|
1356
|
+
const key = e.key.toLowerCase();
|
|
1357
|
+
if (/^[1-9]$/.test(e.key)) {
|
|
1358
|
+
e.preventDefault();
|
|
1359
|
+
const idx = Number(e.key) - 1;
|
|
1360
|
+
if (e.shiftKey) wm.moveFocusedToDesktop(idx);
|
|
1361
|
+
else wm.switchDesktop(idx);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
switch (key) {
|
|
1365
|
+
// Plain Alt+H/V split into an empty pane; with Shift they open
|
|
1366
|
+
// the focused tile's own content in the new split.
|
|
1367
|
+
case "h":
|
|
1368
|
+
e.preventDefault();
|
|
1369
|
+
e.shiftKey ? wm.splitFocusedWith("h") : wm.split("h");
|
|
1370
|
+
return;
|
|
1371
|
+
case "v":
|
|
1372
|
+
e.preventDefault();
|
|
1373
|
+
e.shiftKey ? wm.splitFocusedWith("v") : wm.split("v");
|
|
1374
|
+
return;
|
|
1375
|
+
case "t":
|
|
1376
|
+
e.preventDefault();
|
|
1377
|
+
wm.openFocusedInTab();
|
|
1378
|
+
return;
|
|
1379
|
+
case "n":
|
|
1380
|
+
e.preventDefault();
|
|
1381
|
+
wm.openFocusedInWindow();
|
|
1382
|
+
return;
|
|
1383
|
+
case "w":
|
|
1384
|
+
e.preventDefault();
|
|
1385
|
+
wm.closeFocused();
|
|
1386
|
+
return;
|
|
1387
|
+
case "f":
|
|
1388
|
+
e.preventDefault();
|
|
1389
|
+
wm.toggleManagedFocused();
|
|
1390
|
+
return;
|
|
1391
|
+
case "arrowleft":
|
|
1392
|
+
e.preventDefault();
|
|
1393
|
+
e.shiftKey ? wm.moveFocused("left") : wm.focusDir("left");
|
|
1394
|
+
return;
|
|
1395
|
+
case "arrowright":
|
|
1396
|
+
e.preventDefault();
|
|
1397
|
+
e.shiftKey ? wm.moveFocused("right") : wm.focusDir("right");
|
|
1398
|
+
return;
|
|
1399
|
+
case "arrowup":
|
|
1400
|
+
e.preventDefault();
|
|
1401
|
+
e.shiftKey ? wm.moveFocused("up") : wm.focusDir("up");
|
|
1402
|
+
return;
|
|
1403
|
+
case "arrowdown":
|
|
1404
|
+
e.preventDefault();
|
|
1405
|
+
e.shiftKey ? wm.moveFocused("down") : wm.focusDir("down");
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (key.length === 1 && /[a-z]/.test(key) && !inField) {
|
|
1409
|
+
e.preventDefault();
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// src/tiling/kind_taxonomy.js
|
|
1415
|
+
function createTaxonomy({ kinds, root } = {}) {
|
|
1416
|
+
if (!kinds || typeof kinds !== "object") {
|
|
1417
|
+
throw new TypeError("createTaxonomy: `kinds` must be an object of KindDefs");
|
|
1418
|
+
}
|
|
1419
|
+
if (!root || !kinds[root]) {
|
|
1420
|
+
throw new Error(`createTaxonomy: root kind '${root}' is not in the taxonomy`);
|
|
1421
|
+
}
|
|
1422
|
+
for (const [kind, def] of Object.entries(kinds)) {
|
|
1423
|
+
if (!def || typeof def !== "object") {
|
|
1424
|
+
throw new TypeError(`createTaxonomy: '${kind}' is not a KindDef`);
|
|
1425
|
+
}
|
|
1426
|
+
if (def.topNav && !kinds[def.topNav]) {
|
|
1427
|
+
throw new Error(
|
|
1428
|
+
`createTaxonomy: '${kind}' hangs off unknown topNav '${def.topNav}'`
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
if (def.isTopNav && def.topNav) {
|
|
1432
|
+
throw new Error(
|
|
1433
|
+
`createTaxonomy: '${kind}' is both a top-nav kind and owned by one`
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const _kinds = Object.freeze({ ...kinds });
|
|
1438
|
+
const meta = (kind) => _kinds[kind];
|
|
1439
|
+
const topNavFor = (kind) => {
|
|
1440
|
+
const m = _kinds[kind];
|
|
1441
|
+
if (!m) return void 0;
|
|
1442
|
+
return m.isTopNav ? kind : m.topNav;
|
|
1443
|
+
};
|
|
1444
|
+
const parentKindFor = (kind) => {
|
|
1445
|
+
if (kind === root) return null;
|
|
1446
|
+
const m = _kinds[kind];
|
|
1447
|
+
if (!m) return null;
|
|
1448
|
+
if (m.isTopNav) return root;
|
|
1449
|
+
return m.topNav || root;
|
|
1450
|
+
};
|
|
1451
|
+
const ancestors = (kind, props) => {
|
|
1452
|
+
const fn = _kinds[kind]?.ancestors;
|
|
1453
|
+
if (typeof fn !== "function") return [];
|
|
1454
|
+
let out;
|
|
1455
|
+
try {
|
|
1456
|
+
out = fn(props || {});
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
console.warn("[taxonomy] ancestors() threw for", kind, err);
|
|
1459
|
+
return [];
|
|
1460
|
+
}
|
|
1461
|
+
if (!Array.isArray(out)) return [];
|
|
1462
|
+
return out.filter((a) => a && a.kind).map((a) => ({ kind: a.kind, props: { id: a.id, label: a.label ?? a.id } }));
|
|
1463
|
+
};
|
|
1464
|
+
const parentOf = (kind, props) => {
|
|
1465
|
+
const chain = ancestors(kind, props);
|
|
1466
|
+
return chain.length ? chain[chain.length - 1] : null;
|
|
1467
|
+
};
|
|
1468
|
+
const labelOf = (kind, props) => {
|
|
1469
|
+
const fn = _kinds[kind]?.labelOf;
|
|
1470
|
+
if (typeof fn === "function") {
|
|
1471
|
+
try {
|
|
1472
|
+
const s = fn(props || {});
|
|
1473
|
+
if (s) return String(s);
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
console.warn("[taxonomy] labelOf() threw for", kind, err);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (props?.label) return String(props.label);
|
|
1479
|
+
return props?.id != null ? String(props.id) : "";
|
|
1480
|
+
};
|
|
1481
|
+
const sourcesFor = (kind) => {
|
|
1482
|
+
const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
|
|
1483
|
+
return Array.isArray(s) ? s : [];
|
|
1484
|
+
};
|
|
1485
|
+
const topNavEntries = () => Object.entries(_kinds).filter(([, m]) => m.isTopNav).sort((a, b) => (a[1].order ?? 1e3) - (b[1].order ?? 1e3)).map(([kind, m]) => ({
|
|
1486
|
+
kind,
|
|
1487
|
+
label: m.shortLabel || m.label,
|
|
1488
|
+
longLabel: m.label,
|
|
1489
|
+
icon: m.icon
|
|
1490
|
+
}));
|
|
1491
|
+
return Object.freeze({
|
|
1492
|
+
root,
|
|
1493
|
+
kinds: _kinds,
|
|
1494
|
+
meta,
|
|
1495
|
+
topNavFor,
|
|
1496
|
+
parentKindFor,
|
|
1497
|
+
ancestors,
|
|
1498
|
+
parentOf,
|
|
1499
|
+
labelOf,
|
|
1500
|
+
sourcesFor,
|
|
1501
|
+
topNavEntries
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/tiling/panel_keys.js
|
|
1506
|
+
var _router = null;
|
|
1507
|
+
function installPanelKeyRouter(wm) {
|
|
1508
|
+
if (_router) _router.destroy();
|
|
1509
|
+
_router = new PanelKeyRouter(wm);
|
|
1510
|
+
return _router;
|
|
1511
|
+
}
|
|
1512
|
+
function getPanelKeyRouter() {
|
|
1513
|
+
return _router;
|
|
1514
|
+
}
|
|
1515
|
+
function uninstallPanelKeyRouter() {
|
|
1516
|
+
_router?.destroy();
|
|
1517
|
+
_router = null;
|
|
1518
|
+
}
|
|
1519
|
+
function registerPanelKeys(hostEl, handler) {
|
|
1520
|
+
if (!hostEl || typeof handler !== "function") return () => {
|
|
1521
|
+
};
|
|
1522
|
+
if (_router) return _router.register(hostEl, handler);
|
|
1523
|
+
return _legacyRegister(hostEl, handler);
|
|
1524
|
+
}
|
|
1525
|
+
var PanelKeyRouter = class {
|
|
1526
|
+
constructor(wm) {
|
|
1527
|
+
this._wm = wm;
|
|
1528
|
+
this._entries = [];
|
|
1529
|
+
this._onKey = this._dispatch.bind(this);
|
|
1530
|
+
document.addEventListener("keydown", this._onKey);
|
|
1531
|
+
}
|
|
1532
|
+
register(hostEl, handler) {
|
|
1533
|
+
const entry = { hostEl, handler };
|
|
1534
|
+
this._entries.push(entry);
|
|
1535
|
+
return () => {
|
|
1536
|
+
const i = this._entries.indexOf(entry);
|
|
1537
|
+
if (i >= 0) this._entries.splice(i, 1);
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
destroy() {
|
|
1541
|
+
document.removeEventListener("keydown", this._onKey);
|
|
1542
|
+
this._entries.length = 0;
|
|
1543
|
+
}
|
|
1544
|
+
/** Is `hostEl` the panel that should receive keys right now? */
|
|
1545
|
+
_isActiveTarget(hostEl) {
|
|
1546
|
+
if (!hostEl?.isConnected) return false;
|
|
1547
|
+
const leaf = hostEl.closest?.(".twm-leaf");
|
|
1548
|
+
if (leaf) {
|
|
1549
|
+
return leaf === this._wm?.focusedLeafEl?.();
|
|
1550
|
+
}
|
|
1551
|
+
return hostEl.contains(document.activeElement) || document.activeElement === document.body;
|
|
1552
|
+
}
|
|
1553
|
+
_dispatch(ev) {
|
|
1554
|
+
for (const entry of [...this._entries]) {
|
|
1555
|
+
if (!this._isActiveTarget(entry.hostEl)) continue;
|
|
1556
|
+
try {
|
|
1557
|
+
entry.handler(ev);
|
|
1558
|
+
} catch (err) {
|
|
1559
|
+
console.warn("[panel-keys] handler threw", err);
|
|
1560
|
+
}
|
|
1561
|
+
if (ev.defaultPrevented) return;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
function _legacyRegister(hostEl, handler) {
|
|
1566
|
+
const wrapped = (ev) => {
|
|
1567
|
+
if (!hostEl.isConnected) return;
|
|
1568
|
+
if (!hostEl.contains(document.activeElement) && document.activeElement !== document.body) return;
|
|
1569
|
+
handler(ev);
|
|
1570
|
+
};
|
|
1571
|
+
document.addEventListener("keydown", wrapped);
|
|
1572
|
+
return () => document.removeEventListener("keydown", wrapped);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/tiling/landing_table.js
|
|
1576
|
+
function attachLandingTableBehavior(host, getRow, entityActions, options = {}) {
|
|
1577
|
+
if (!host) return () => {
|
|
1578
|
+
};
|
|
1579
|
+
const dispatch = (action, row) => {
|
|
1580
|
+
if (!row) return;
|
|
1581
|
+
const fn = action === "open-tab" ? entityActions.openInTab : action === "open-window" ? entityActions.openInWindow : entityActions[action];
|
|
1582
|
+
try {
|
|
1583
|
+
fn?.(row);
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
console.warn(`[landing] action "${action}" failed`, e);
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
const bodyRowFor = (ev) => {
|
|
1589
|
+
const tr = ev.target.closest("tr");
|
|
1590
|
+
if (!tr || !tr.closest("tbody")) return null;
|
|
1591
|
+
const wrap = tr.closest(".twm-preview-table-wrap");
|
|
1592
|
+
if (!wrap || !host.contains(wrap)) return null;
|
|
1593
|
+
return tr;
|
|
1594
|
+
};
|
|
1595
|
+
const onClick = (ev) => {
|
|
1596
|
+
const actionBtn = ev.target.closest("[data-twm-action]");
|
|
1597
|
+
if (actionBtn && host.contains(actionBtn)) {
|
|
1598
|
+
ev.preventDefault();
|
|
1599
|
+
ev.stopPropagation();
|
|
1600
|
+
ev.stopImmediatePropagation();
|
|
1601
|
+
const tr2 = actionBtn.closest("tr");
|
|
1602
|
+
dispatch(actionBtn.dataset.twmAction, tr2 ? getRow(tr2.__rowIndex) : null);
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
if (ev.target.closest("button, a, input, select, textarea")) return;
|
|
1606
|
+
const tr = bodyRowFor(ev);
|
|
1607
|
+
if (tr) dispatch("open", getRow(tr.__rowIndex));
|
|
1608
|
+
};
|
|
1609
|
+
const defaultMenu = (row) => {
|
|
1610
|
+
const items = [];
|
|
1611
|
+
if (entityActions.open) {
|
|
1612
|
+
items.push({ label: "Open", icon: "open_in_new", action: "open" });
|
|
1613
|
+
if (entityActions.openInTab) {
|
|
1614
|
+
items.push({ label: "Open in new tab", icon: "tab", action: "open-tab" });
|
|
1615
|
+
}
|
|
1616
|
+
if (entityActions.openInWindow) {
|
|
1617
|
+
items.push({
|
|
1618
|
+
label: "Open in new window",
|
|
1619
|
+
icon: "open_in_full",
|
|
1620
|
+
action: "open-window"
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (entityActions.edit) items.push({ label: "Rename / edit", icon: "edit", action: "edit" });
|
|
1625
|
+
if (entityActions.delete) {
|
|
1626
|
+
if (items.length) items.push({ separator: true });
|
|
1627
|
+
items.push({ label: "Delete", icon: "delete", action: "delete", danger: true });
|
|
1628
|
+
}
|
|
1629
|
+
return items;
|
|
1630
|
+
};
|
|
1631
|
+
const buildMenu = typeof options.buildMenu === "function" ? options.buildMenu : defaultMenu;
|
|
1632
|
+
const onContextMenu = (ev) => {
|
|
1633
|
+
const tr = bodyRowFor(ev);
|
|
1634
|
+
if (!tr) return;
|
|
1635
|
+
ev.preventDefault();
|
|
1636
|
+
ev.stopPropagation();
|
|
1637
|
+
const row = getRow(tr.__rowIndex);
|
|
1638
|
+
if (!row) return;
|
|
1639
|
+
const items = buildMenu(row) || [];
|
|
1640
|
+
if (items.length === 0) return;
|
|
1641
|
+
showContextMenu(ev.clientX, ev.clientY, items, (action) => dispatch(action, row));
|
|
1642
|
+
};
|
|
1643
|
+
host.addEventListener("click", onClick);
|
|
1644
|
+
host.addEventListener("contextmenu", onContextMenu, true);
|
|
1645
|
+
return () => {
|
|
1646
|
+
host.removeEventListener("click", onClick);
|
|
1647
|
+
host.removeEventListener("contextmenu", onContextMenu, true);
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
function wireLandingPaneFocus(hostEl) {
|
|
1651
|
+
const panes = Array.from(hostEl.querySelectorAll(".twm-landing__pane"));
|
|
1652
|
+
if (panes.length === 0) return { focus: () => {
|
|
1653
|
+
}, panes };
|
|
1654
|
+
let focused = panes[0];
|
|
1655
|
+
const reflect = () => {
|
|
1656
|
+
for (const p of panes) {
|
|
1657
|
+
p.classList.toggle("twm-landing__pane--focused", p === focused);
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
reflect();
|
|
1661
|
+
for (const p of panes) {
|
|
1662
|
+
p.addEventListener("mousedown", () => {
|
|
1663
|
+
if (focused === p) return;
|
|
1664
|
+
focused = p;
|
|
1665
|
+
reflect();
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
focus: (pane) => {
|
|
1670
|
+
if (pane && panes.includes(pane)) {
|
|
1671
|
+
focused = pane;
|
|
1672
|
+
reflect();
|
|
1673
|
+
}
|
|
1674
|
+
},
|
|
1675
|
+
panes
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
function attachLandingKeyboardNav(host, getRows, onActivate, options = {}) {
|
|
1679
|
+
if (!host) return { teardown: () => {
|
|
1680
|
+
}, refresh: () => {
|
|
1681
|
+
}, focusFirst: () => {
|
|
1682
|
+
} };
|
|
1683
|
+
let cursor = 0;
|
|
1684
|
+
const tbody = () => host.querySelector("tbody");
|
|
1685
|
+
const repaintCursor = () => {
|
|
1686
|
+
const tb = tbody();
|
|
1687
|
+
if (!tb) return;
|
|
1688
|
+
const rows = getRows() || [];
|
|
1689
|
+
if (rows.length === 0) {
|
|
1690
|
+
cursor = 0;
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (cursor >= rows.length) cursor = rows.length - 1;
|
|
1694
|
+
if (cursor < 0) cursor = 0;
|
|
1695
|
+
let active = null;
|
|
1696
|
+
tb.querySelectorAll("tr").forEach((tr) => {
|
|
1697
|
+
const on = tr.__rowIndex === cursor;
|
|
1698
|
+
tr.classList.toggle("twm-row-cursor", on);
|
|
1699
|
+
if (on) active = tr;
|
|
1700
|
+
});
|
|
1701
|
+
if (active) active.scrollIntoView({ block: "nearest" });
|
|
1702
|
+
};
|
|
1703
|
+
const move = (delta) => {
|
|
1704
|
+
const rows = getRows() || [];
|
|
1705
|
+
if (rows.length === 0) return;
|
|
1706
|
+
cursor = Math.max(0, Math.min(rows.length - 1, cursor + delta));
|
|
1707
|
+
repaintCursor();
|
|
1708
|
+
};
|
|
1709
|
+
const open = () => {
|
|
1710
|
+
const rows = getRows() || [];
|
|
1711
|
+
const row = rows[cursor];
|
|
1712
|
+
if (row) onActivate(row);
|
|
1713
|
+
};
|
|
1714
|
+
const onKey = (ev) => {
|
|
1715
|
+
if (!host.isConnected) return;
|
|
1716
|
+
const t = ev.target;
|
|
1717
|
+
const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
|
|
1718
|
+
if (editable) return;
|
|
1719
|
+
if (ev.key === "ArrowDown") {
|
|
1720
|
+
ev.preventDefault();
|
|
1721
|
+
move(1);
|
|
1722
|
+
} else if (ev.key === "ArrowUp") {
|
|
1723
|
+
ev.preventDefault();
|
|
1724
|
+
move(-1);
|
|
1725
|
+
} else if (ev.key === "Home") {
|
|
1726
|
+
ev.preventDefault();
|
|
1727
|
+
cursor = 0;
|
|
1728
|
+
repaintCursor();
|
|
1729
|
+
} else if (ev.key === "End") {
|
|
1730
|
+
ev.preventDefault();
|
|
1731
|
+
const rows = getRows() || [];
|
|
1732
|
+
cursor = Math.max(0, rows.length - 1);
|
|
1733
|
+
repaintCursor();
|
|
1734
|
+
} else if (ev.key === "Enter") {
|
|
1735
|
+
ev.preventDefault();
|
|
1736
|
+
open();
|
|
1737
|
+
} else if (options.activateKey && ev.key === options.activateKey && !ev.metaKey && !ev.ctrlKey && !ev.altKey) {
|
|
1738
|
+
ev.preventDefault();
|
|
1739
|
+
open();
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
const offKey = registerPanelKeys(host, onKey);
|
|
1743
|
+
setTimeout(repaintCursor, 0);
|
|
1744
|
+
return {
|
|
1745
|
+
teardown: () => offKey(),
|
|
1746
|
+
refresh: () => repaintCursor(),
|
|
1747
|
+
focusFirst: () => {
|
|
1748
|
+
cursor = 0;
|
|
1749
|
+
repaintCursor();
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
function attachLandingShell(hostEl, { panes, initialPane = 0 } = {}) {
|
|
1754
|
+
if (!hostEl || !Array.isArray(panes) || panes.length === 0) {
|
|
1755
|
+
return { teardown: () => {
|
|
1756
|
+
}, refresh: () => {
|
|
1757
|
+
}, focusPane: () => {
|
|
1758
|
+
} };
|
|
1759
|
+
}
|
|
1760
|
+
const state = panes.map((spec) => ({
|
|
1761
|
+
spec,
|
|
1762
|
+
paneEl: spec.paneEl,
|
|
1763
|
+
tableHost: spec.tableHost || spec.paneEl?.querySelector(".twm-landing__table") || spec.paneEl,
|
|
1764
|
+
cursor: 0
|
|
1765
|
+
}));
|
|
1766
|
+
let focusedIdx = Math.max(0, Math.min(panes.length - 1, initialPane));
|
|
1767
|
+
const reflectPaneFocus = () => {
|
|
1768
|
+
state.forEach((s, i) => {
|
|
1769
|
+
s.paneEl?.classList.toggle(
|
|
1770
|
+
"twm-landing__pane--focused",
|
|
1771
|
+
i === focusedIdx
|
|
1772
|
+
);
|
|
1773
|
+
});
|
|
1774
|
+
};
|
|
1775
|
+
const repaintCursor = (idx = focusedIdx) => {
|
|
1776
|
+
const s = state[idx];
|
|
1777
|
+
if (!s) return;
|
|
1778
|
+
const tbody = s.tableHost?.querySelector("tbody");
|
|
1779
|
+
if (!tbody) return;
|
|
1780
|
+
const rows = s.spec.getRows?.() || [];
|
|
1781
|
+
if (rows.length === 0) {
|
|
1782
|
+
s.cursor = 0;
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (s.cursor >= rows.length) s.cursor = rows.length - 1;
|
|
1786
|
+
if (s.cursor < 0) s.cursor = 0;
|
|
1787
|
+
let active = null;
|
|
1788
|
+
tbody.querySelectorAll("tr").forEach((tr) => {
|
|
1789
|
+
const on = tr.__rowIndex === s.cursor;
|
|
1790
|
+
tr.classList.toggle("twm-row-cursor", on);
|
|
1791
|
+
if (on) active = tr;
|
|
1792
|
+
});
|
|
1793
|
+
if (active && idx === focusedIdx) {
|
|
1794
|
+
active.scrollIntoView({ block: "nearest" });
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
const move = (delta) => {
|
|
1798
|
+
const s = state[focusedIdx];
|
|
1799
|
+
if (!s) return;
|
|
1800
|
+
const rows = s.spec.getRows?.() || [];
|
|
1801
|
+
if (rows.length === 0) return;
|
|
1802
|
+
s.cursor = Math.max(0, Math.min(rows.length - 1, s.cursor + delta));
|
|
1803
|
+
repaintCursor();
|
|
1804
|
+
};
|
|
1805
|
+
const jump = (toEnd) => {
|
|
1806
|
+
const s = state[focusedIdx];
|
|
1807
|
+
if (!s) return;
|
|
1808
|
+
const rows = s.spec.getRows?.() || [];
|
|
1809
|
+
s.cursor = toEnd ? Math.max(0, rows.length - 1) : 0;
|
|
1810
|
+
repaintCursor();
|
|
1811
|
+
};
|
|
1812
|
+
const currentRow = () => {
|
|
1813
|
+
const s = state[focusedIdx];
|
|
1814
|
+
if (!s) return null;
|
|
1815
|
+
const rows = s.spec.getRows?.() || [];
|
|
1816
|
+
return rows[s.cursor] ?? null;
|
|
1817
|
+
};
|
|
1818
|
+
const activate = () => {
|
|
1819
|
+
const row = currentRow();
|
|
1820
|
+
if (!row) return;
|
|
1821
|
+
try {
|
|
1822
|
+
state[focusedIdx].spec.onActivate?.(row);
|
|
1823
|
+
} catch (err) {
|
|
1824
|
+
console.warn("[landing-shell] activate failed", err);
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
const activateInTab = () => {
|
|
1828
|
+
const row = currentRow();
|
|
1829
|
+
if (!row) return;
|
|
1830
|
+
const spec = state[focusedIdx].spec;
|
|
1831
|
+
const fn = spec.onActivateInTab || spec.onActivate;
|
|
1832
|
+
try {
|
|
1833
|
+
fn?.(row);
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
console.warn("[landing-shell] activate-tab failed", err);
|
|
1836
|
+
}
|
|
1837
|
+
};
|
|
1838
|
+
const activateInWindow = () => {
|
|
1839
|
+
const row = currentRow();
|
|
1840
|
+
if (!row) return;
|
|
1841
|
+
const spec = state[focusedIdx].spec;
|
|
1842
|
+
const fn = spec.onActivateInWindow || spec.onActivate;
|
|
1843
|
+
try {
|
|
1844
|
+
fn?.(row);
|
|
1845
|
+
} catch (err) {
|
|
1846
|
+
console.warn("[landing-shell] activate-window failed", err);
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
const expandToggle = (expand) => {
|
|
1850
|
+
const s = state[focusedIdx];
|
|
1851
|
+
if (!s?.spec.onExpandToggle) return false;
|
|
1852
|
+
const row = currentRow();
|
|
1853
|
+
if (!row) return false;
|
|
1854
|
+
if (s.spec.isExpandable && !s.spec.isExpandable(row)) return false;
|
|
1855
|
+
const currentlyExpanded = s.spec.isExpanded ? !!s.spec.isExpanded(row) : false;
|
|
1856
|
+
if (expand === currentlyExpanded) return false;
|
|
1857
|
+
try {
|
|
1858
|
+
s.spec.onExpandToggle(row, expand);
|
|
1859
|
+
} catch (err) {
|
|
1860
|
+
console.warn("[landing-shell] expand failed", err);
|
|
1861
|
+
}
|
|
1862
|
+
return true;
|
|
1863
|
+
};
|
|
1864
|
+
const focusPane = (idx) => {
|
|
1865
|
+
if (idx < 0 || idx >= state.length) return;
|
|
1866
|
+
if (idx === focusedIdx) return;
|
|
1867
|
+
focusedIdx = idx;
|
|
1868
|
+
reflectPaneFocus();
|
|
1869
|
+
repaintCursor();
|
|
1870
|
+
};
|
|
1871
|
+
const cyclePane = (dir) => {
|
|
1872
|
+
if (state.length < 2) return;
|
|
1873
|
+
focusPane((focusedIdx + dir + state.length) % state.length);
|
|
1874
|
+
};
|
|
1875
|
+
const onMouseDown = (ev) => {
|
|
1876
|
+
const paneEl = ev.target.closest?.(".twm-landing__pane");
|
|
1877
|
+
if (!paneEl) return;
|
|
1878
|
+
const idx = state.findIndex((s) => s.paneEl === paneEl);
|
|
1879
|
+
if (idx < 0 || idx === focusedIdx) return;
|
|
1880
|
+
focusedIdx = idx;
|
|
1881
|
+
reflectPaneFocus();
|
|
1882
|
+
repaintCursor();
|
|
1883
|
+
};
|
|
1884
|
+
const onKey = (ev) => {
|
|
1885
|
+
if (!hostEl.isConnected) return;
|
|
1886
|
+
const t = ev.target;
|
|
1887
|
+
const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
|
|
1888
|
+
if (editable) return;
|
|
1889
|
+
if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey) && !ev.altKey) {
|
|
1890
|
+
ev.preventDefault();
|
|
1891
|
+
activateInTab();
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
if (ev.key === "Enter" && ev.shiftKey && !ev.altKey && !ev.metaKey && !ev.ctrlKey) {
|
|
1895
|
+
ev.preventDefault();
|
|
1896
|
+
activateInWindow();
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.shiftKey) return;
|
|
1900
|
+
switch (ev.key) {
|
|
1901
|
+
case "ArrowDown":
|
|
1902
|
+
ev.preventDefault();
|
|
1903
|
+
move(1);
|
|
1904
|
+
return;
|
|
1905
|
+
case "ArrowUp":
|
|
1906
|
+
ev.preventDefault();
|
|
1907
|
+
move(-1);
|
|
1908
|
+
return;
|
|
1909
|
+
case "Home":
|
|
1910
|
+
ev.preventDefault();
|
|
1911
|
+
jump(false);
|
|
1912
|
+
return;
|
|
1913
|
+
case "End":
|
|
1914
|
+
ev.preventDefault();
|
|
1915
|
+
jump(true);
|
|
1916
|
+
return;
|
|
1917
|
+
case "Enter":
|
|
1918
|
+
ev.preventDefault();
|
|
1919
|
+
activate();
|
|
1920
|
+
return;
|
|
1921
|
+
case "Tab":
|
|
1922
|
+
ev.preventDefault();
|
|
1923
|
+
cyclePane(ev.shiftKey ? -1 : 1);
|
|
1924
|
+
return;
|
|
1925
|
+
case "ArrowRight":
|
|
1926
|
+
if (expandToggle(true)) ev.preventDefault();
|
|
1927
|
+
return;
|
|
1928
|
+
case "ArrowLeft":
|
|
1929
|
+
if (expandToggle(false)) ev.preventDefault();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
hostEl.addEventListener("mousedown", onMouseDown);
|
|
1934
|
+
const offKey = registerPanelKeys(hostEl, onKey);
|
|
1935
|
+
reflectPaneFocus();
|
|
1936
|
+
setTimeout(() => state.forEach((_, i) => repaintCursor(i)), 0);
|
|
1937
|
+
const parkFocusIfActive = () => {
|
|
1938
|
+
const leaf = hostEl.closest?.(".twm-leaf");
|
|
1939
|
+
const inTile = !!leaf;
|
|
1940
|
+
if (inTile && !leaf.classList.contains("twm-leaf--focused")) return;
|
|
1941
|
+
if (hostEl.contains(document.activeElement)) return;
|
|
1942
|
+
if (hostEl.tabIndex == null || hostEl.tabIndex < -1) hostEl.tabIndex = -1;
|
|
1943
|
+
try {
|
|
1944
|
+
hostEl.focus({ preventScroll: true });
|
|
1945
|
+
} catch {
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
parkFocusIfActive();
|
|
1949
|
+
requestAnimationFrame(parkFocusIfActive);
|
|
1950
|
+
return {
|
|
1951
|
+
teardown: () => {
|
|
1952
|
+
hostEl.removeEventListener("mousedown", onMouseDown);
|
|
1953
|
+
offKey();
|
|
1954
|
+
},
|
|
1955
|
+
refresh: () => state.forEach((_, i) => repaintCursor(i)),
|
|
1956
|
+
focusPane,
|
|
1957
|
+
currentRow
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
function mountLandingShell(hostEl, opts) {
|
|
1961
|
+
const {
|
|
1962
|
+
title = "",
|
|
1963
|
+
stats = [],
|
|
1964
|
+
pane = { key: "rows", title: "", hint: "" }
|
|
1965
|
+
} = opts || {};
|
|
1966
|
+
const paneKey = String(pane.key || "rows");
|
|
1967
|
+
const _esc8 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
1968
|
+
"&": "&",
|
|
1969
|
+
"<": "<",
|
|
1970
|
+
">": ">",
|
|
1971
|
+
'"': """,
|
|
1972
|
+
"'": "'"
|
|
1973
|
+
})[c]);
|
|
1974
|
+
const statsHtml = (stats || []).map((s) => `
|
|
1975
|
+
<span class="twm-landing__stat twm-entity-header__field">
|
|
1976
|
+
<span>${_esc8(s.label || "")}</span>
|
|
1977
|
+
<strong data-role="${_esc8(s.key)}">${_esc8(s.initial != null ? s.initial : 0)}</strong>
|
|
1978
|
+
</span>
|
|
1979
|
+
`).join("");
|
|
1980
|
+
hostEl.classList.add("twm-landing");
|
|
1981
|
+
hostEl.innerHTML = `
|
|
1982
|
+
<header class="twm-entity-header">
|
|
1983
|
+
<h2>${_esc8(title)}</h2>
|
|
1984
|
+
${statsHtml}
|
|
1985
|
+
<span class="twm-entity-header__spacer"></span>
|
|
1986
|
+
</header>
|
|
1987
|
+
<div class="twm-landing__split twm-landing__split--single">
|
|
1988
|
+
<section class="twm-landing__pane" data-pane="${_esc8(paneKey)}">
|
|
1989
|
+
<div class="twm-landing__pane-head">
|
|
1990
|
+
<span class="twm-landing__pane-title">${_esc8(pane.title || "")}</span>
|
|
1991
|
+
<span class="twm-landing__pane-hint">${_esc8(pane.hint || "")}</span>
|
|
1992
|
+
</div>
|
|
1993
|
+
<div class="twm-landing__table" data-role="${_esc8(paneKey)}-host"></div>
|
|
1994
|
+
</section>
|
|
1995
|
+
</div>
|
|
1996
|
+
<footer class="twm-landing__actions" data-role="actions"></footer>
|
|
1997
|
+
`;
|
|
1998
|
+
const headerEl = hostEl.querySelector(".twm-entity-header");
|
|
1999
|
+
const paneEl = hostEl.querySelector(`[data-pane="${paneKey}"]`);
|
|
2000
|
+
const tableHost = hostEl.querySelector(`[data-role="${paneKey}-host"]`);
|
|
2001
|
+
const footerEl = hostEl.querySelector('[data-role="actions"]');
|
|
2002
|
+
const setStat = (key, value) => {
|
|
2003
|
+
const el = hostEl.querySelector(`[data-role="${key}"]`);
|
|
2004
|
+
if (el) el.textContent = String(value);
|
|
2005
|
+
};
|
|
2006
|
+
return { headerEl, paneEl, tableHost, footerEl, setStat };
|
|
2007
|
+
}
|
|
2008
|
+
function mountLandingActions(footerEl, actions, hostEl) {
|
|
2009
|
+
if (!footerEl) return { teardown: () => {
|
|
2010
|
+
}, refresh: () => {
|
|
2011
|
+
} };
|
|
2012
|
+
let current = [];
|
|
2013
|
+
const renderInto = (list) => {
|
|
2014
|
+
current = (list || []).filter(Boolean);
|
|
2015
|
+
footerEl.innerHTML = "";
|
|
2016
|
+
for (const a of current) {
|
|
2017
|
+
const btn = document.createElement("button");
|
|
2018
|
+
btn.type = "button";
|
|
2019
|
+
btn.className = "twm-action-btn";
|
|
2020
|
+
if (a.id) btn.dataset.action = a.id;
|
|
2021
|
+
if (a.title) btn.title = a.title;
|
|
2022
|
+
const icon = a.icon ? `<span class="material-symbols-outlined">${_esc3(a.icon)}</span>` : "";
|
|
2023
|
+
const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc3(a.shortcut.toUpperCase())}]</span>` : "";
|
|
2024
|
+
btn.innerHTML = `${icon}<span>${_esc3(a.label || "")}</span>${sc}`;
|
|
2025
|
+
btn.addEventListener("click", (ev) => {
|
|
2026
|
+
ev.preventDefault();
|
|
2027
|
+
try {
|
|
2028
|
+
a.onClick?.(ev);
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
console.warn("[landing-actions] click failed", err);
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
footerEl.appendChild(btn);
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
const onKey = (ev) => {
|
|
2037
|
+
if (!footerEl.isConnected) return;
|
|
2038
|
+
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
2039
|
+
const t = ev.target;
|
|
2040
|
+
const editable = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
|
|
2041
|
+
if (editable) return;
|
|
2042
|
+
const k = ev.key.length === 1 ? ev.key.toLowerCase() : ev.key;
|
|
2043
|
+
const hit = current.find((a) => a.shortcut && String(a.shortcut).toLowerCase() === k);
|
|
2044
|
+
if (!hit) return;
|
|
2045
|
+
ev.preventDefault();
|
|
2046
|
+
try {
|
|
2047
|
+
hit.onClick?.(ev);
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
console.warn("[landing-actions] shortcut failed", err);
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
renderInto(actions);
|
|
2053
|
+
const scopeEl = hostEl || footerEl.closest(".twm-landing") || footerEl;
|
|
2054
|
+
const offKey = registerPanelKeys(scopeEl, onKey);
|
|
2055
|
+
return {
|
|
2056
|
+
teardown: () => offKey(),
|
|
2057
|
+
refresh: renderInto
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true }) {
|
|
2061
|
+
return (td, _value, colIdx) => {
|
|
2062
|
+
if (colIdx !== actionsColIdx) return false;
|
|
2063
|
+
const parts = [];
|
|
2064
|
+
if (options.edit) {
|
|
2065
|
+
parts.push(`<button type="button" class="twm-row-action"
|
|
2066
|
+
data-twm-action="edit"
|
|
2067
|
+
title="Edit"
|
|
2068
|
+
aria-label="Edit">
|
|
2069
|
+
<span class="material-symbols-outlined">edit</span>
|
|
2070
|
+
</button>`);
|
|
2071
|
+
}
|
|
2072
|
+
if (options.delete) {
|
|
2073
|
+
parts.push(`<button type="button" class="twm-row-action twm-row-action--danger"
|
|
2074
|
+
data-twm-action="delete"
|
|
2075
|
+
title="Delete"
|
|
2076
|
+
aria-label="Delete">
|
|
2077
|
+
<span class="material-symbols-outlined">delete</span>
|
|
2078
|
+
</button>`);
|
|
2079
|
+
}
|
|
2080
|
+
td.classList.add("twm-row-actions-cell");
|
|
2081
|
+
td.innerHTML = parts.join("");
|
|
2082
|
+
return true;
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function _esc3(s) {
|
|
2086
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
2087
|
+
"&": "&",
|
|
2088
|
+
"<": "<",
|
|
2089
|
+
">": ">",
|
|
2090
|
+
'"': """,
|
|
2091
|
+
"'": "'"
|
|
2092
|
+
})[c]);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// src/tiling/loading_overlay.js
|
|
2096
|
+
function makeLoadingOverlay(slot, { delay = 80 } = {}) {
|
|
2097
|
+
const overlay = document.createElement("div");
|
|
2098
|
+
overlay.className = "twm-tile-loading twm-tile-loading--pending";
|
|
2099
|
+
overlay.innerHTML = '<div class="twm-tile-loading__spinner" aria-label="Loading"></div>';
|
|
2100
|
+
slot.appendChild(overlay);
|
|
2101
|
+
const showTimer = setTimeout(() => {
|
|
2102
|
+
overlay.classList.remove("twm-tile-loading--pending");
|
|
2103
|
+
}, delay);
|
|
2104
|
+
return () => {
|
|
2105
|
+
clearTimeout(showTimer);
|
|
2106
|
+
overlay.remove();
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// src/tiling/nav_panel.js
|
|
2111
|
+
function mountNavPanel(hostEl, {
|
|
2112
|
+
modes = [],
|
|
2113
|
+
wm = null,
|
|
2114
|
+
eventBus = null,
|
|
2115
|
+
filterPlaceholder = "Filter\u2026"
|
|
2116
|
+
} = {}) {
|
|
2117
|
+
if (!Array.isArray(modes) || modes.length === 0) {
|
|
2118
|
+
throw new Error("mountNavPanel: at least one mode is required");
|
|
2119
|
+
}
|
|
2120
|
+
hostEl.classList.add("twm-nav");
|
|
2121
|
+
hostEl.innerHTML = `
|
|
2122
|
+
<div class="twm-nav__modes" data-role="modes"></div>
|
|
2123
|
+
<div class="twm-sidebar-controls twm-nav__filter">
|
|
2124
|
+
<span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
|
|
2125
|
+
<input class="twm-data-page__search twm-nav__filter-input"
|
|
2126
|
+
type="search" placeholder="${_esc4(filterPlaceholder)}"
|
|
2127
|
+
autocomplete="off" />
|
|
2128
|
+
</div>
|
|
2129
|
+
<div class="twm-nav__body" data-role="body"></div>
|
|
2130
|
+
`;
|
|
2131
|
+
const modesEl = hostEl.querySelector('[data-role="modes"]');
|
|
2132
|
+
const bodyEl = hostEl.querySelector('[data-role="body"]');
|
|
2133
|
+
const filterEl = hostEl.querySelector(".twm-nav__filter-input");
|
|
2134
|
+
modesEl.innerHTML = modes.map((m) => `
|
|
2135
|
+
<button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc4(m.id)}"
|
|
2136
|
+
data-tooltip="${_esc4(m.label)}">
|
|
2137
|
+
<span class="material-symbols-outlined">${_esc4(m.icon)}</span>
|
|
2138
|
+
<span>${_esc4(m.label)}</span>
|
|
2139
|
+
</button>
|
|
2140
|
+
`).join("");
|
|
2141
|
+
const byId = new Map(modes.map((m) => [m.id, m]));
|
|
2142
|
+
const hosts = {};
|
|
2143
|
+
const panes = {};
|
|
2144
|
+
const filterByMode = Object.fromEntries(modes.map((m) => [m.id, ""]));
|
|
2145
|
+
let mode = modes[0].id;
|
|
2146
|
+
const ensureHost = (id) => {
|
|
2147
|
+
if (hosts[id]) return hosts[id];
|
|
2148
|
+
const el = document.createElement("div");
|
|
2149
|
+
el.className = `twm-nav__pane twm-nav__pane--${id}`;
|
|
2150
|
+
el.style.display = "none";
|
|
2151
|
+
bodyEl.appendChild(el);
|
|
2152
|
+
hosts[id] = el;
|
|
2153
|
+
return el;
|
|
2154
|
+
};
|
|
2155
|
+
const activate = async (next) => {
|
|
2156
|
+
const def = byId.get(next);
|
|
2157
|
+
if (!def) return;
|
|
2158
|
+
mode = next;
|
|
2159
|
+
for (const m of modes) {
|
|
2160
|
+
ensureHost(m.id).style.display = m.id === next ? "flex" : "none";
|
|
2161
|
+
}
|
|
2162
|
+
modesEl.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
|
|
2163
|
+
filterEl.value = filterByMode[next] || "";
|
|
2164
|
+
const paneEl = ensureHost(next);
|
|
2165
|
+
if (!panes[next]) {
|
|
2166
|
+
try {
|
|
2167
|
+
panes[next] = def.mount(paneEl, { wm, eventBus }) || {};
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
console.error("[nav] mode mount failed", next, err);
|
|
2170
|
+
panes[next] = {};
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
try {
|
|
2174
|
+
await panes[next].activate?.();
|
|
2175
|
+
} catch (err) {
|
|
2176
|
+
console.error("[nav] mode activate failed", next, err);
|
|
2177
|
+
}
|
|
2178
|
+
_applyFilter();
|
|
2179
|
+
};
|
|
2180
|
+
const _applyFilter = () => {
|
|
2181
|
+
const q = filterByMode[mode] || "";
|
|
2182
|
+
try {
|
|
2183
|
+
panes[mode]?.filter?.(q);
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.warn("[nav] filter failed", mode, err);
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
filterEl.addEventListener("input", () => {
|
|
2189
|
+
filterByMode[mode] = filterEl.value;
|
|
2190
|
+
_applyFilter();
|
|
2191
|
+
});
|
|
2192
|
+
modesEl.addEventListener("click", (e) => {
|
|
2193
|
+
const btn = e.target.closest("[data-mode]");
|
|
2194
|
+
if (btn) activate(btn.dataset.mode);
|
|
2195
|
+
});
|
|
2196
|
+
activate(mode);
|
|
2197
|
+
return {
|
|
2198
|
+
refresh: () => activate(mode),
|
|
2199
|
+
destroy: () => {
|
|
2200
|
+
for (const p of Object.values(panes)) {
|
|
2201
|
+
try {
|
|
2202
|
+
p?.destroy?.();
|
|
2203
|
+
} catch {
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
function _esc4(s) {
|
|
2210
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
// src/tiling/tile_breadcrumb.js
|
|
2214
|
+
function mountTileBreadcrumb(kind, props, ctx) {
|
|
2215
|
+
const eventBus = ctx?.eventBus;
|
|
2216
|
+
const taxonomy = ctx?.taxonomy;
|
|
2217
|
+
const rootCrumb = ctx?.rootCrumb || null;
|
|
2218
|
+
if (!taxonomy) {
|
|
2219
|
+
console.error("[breadcrumb] no taxonomy in ctx \u2014 cannot render");
|
|
2220
|
+
return { el: document.createElement("nav"), destroy: () => {
|
|
2221
|
+
} };
|
|
2222
|
+
}
|
|
2223
|
+
const root = document.createElement("nav");
|
|
2224
|
+
root.className = "twm-topbar-breadcrumb twm-tile-breadcrumb";
|
|
2225
|
+
root.setAttribute("aria-label", "Tile breadcrumb");
|
|
2226
|
+
const getWm = () => ctx?.wm || window.__twm?.wm || null;
|
|
2227
|
+
const navigate = (k, p = {}) => {
|
|
2228
|
+
const wm = getWm();
|
|
2229
|
+
if (!wm) {
|
|
2230
|
+
console.error("[breadcrumb] no WM available \u2014 click ignored", k);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (typeof wm.navigate === "function") {
|
|
2234
|
+
wm.navigate(k, p, { ctx, dest: "origin" });
|
|
2235
|
+
} else if (typeof wm.openInPrimary === "function") {
|
|
2236
|
+
wm.openInPrimary(k, p);
|
|
2237
|
+
} else {
|
|
2238
|
+
console.error("[breadcrumb] WM has no navigate/openInPrimary");
|
|
2239
|
+
}
|
|
2240
|
+
};
|
|
2241
|
+
let rootLabel = rootCrumb?.label || "";
|
|
2242
|
+
const render = () => {
|
|
2243
|
+
_renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
|
|
2244
|
+
};
|
|
2245
|
+
render();
|
|
2246
|
+
let unsubscribe = null;
|
|
2247
|
+
if (typeof rootCrumb?.subscribe === "function") {
|
|
2248
|
+
try {
|
|
2249
|
+
unsubscribe = rootCrumb.subscribe((next) => {
|
|
2250
|
+
if (next?.label) rootLabel = next.label;
|
|
2251
|
+
render();
|
|
2252
|
+
}, { api: ctx?.api, host: ctx?.host, eventBus });
|
|
2253
|
+
} catch (err) {
|
|
2254
|
+
console.warn("[breadcrumb] rootCrumb.subscribe threw", err);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
return {
|
|
2258
|
+
el: root,
|
|
2259
|
+
destroy: () => {
|
|
2260
|
+
try {
|
|
2261
|
+
unsubscribe?.();
|
|
2262
|
+
} catch (err) {
|
|
2263
|
+
console.warn("[breadcrumb] rootCrumb teardown threw", err);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
|
|
2269
|
+
const segs = [];
|
|
2270
|
+
const meta = taxonomy.meta(kind);
|
|
2271
|
+
if (meta?.appGlobal) {
|
|
2272
|
+
return [{ icon: meta.icon, label: meta.label, onClick: null }];
|
|
2273
|
+
}
|
|
2274
|
+
if (rootCrumb) {
|
|
2275
|
+
segs.push({
|
|
2276
|
+
icon: rootCrumb.icon,
|
|
2277
|
+
label: rootLabel || rootCrumb.label,
|
|
2278
|
+
onClick: () => navigate(rootCrumb.navKind ?? taxonomy.root)
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
const topNav = taxonomy.topNavFor(kind);
|
|
2282
|
+
const topNavMeta = topNav ? taxonomy.meta(topNav) : null;
|
|
2283
|
+
if (topNav && topNav !== taxonomy.root && topNavMeta) {
|
|
2284
|
+
segs.push({
|
|
2285
|
+
icon: topNavMeta.icon,
|
|
2286
|
+
label: topNavMeta.label,
|
|
2287
|
+
onClick: () => navigate(topNav)
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
for (const anc of taxonomy.ancestors(kind, props)) {
|
|
2291
|
+
segs.push({
|
|
2292
|
+
icon: taxonomy.meta(anc.kind)?.icon || "description",
|
|
2293
|
+
label: anc.props.label ?? anc.props.id,
|
|
2294
|
+
onClick: () => navigate(anc.kind, anc.props)
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
const hasEntity = !!props?.id;
|
|
2298
|
+
if (meta) {
|
|
2299
|
+
if (hasEntity) {
|
|
2300
|
+
segs.push({
|
|
2301
|
+
icon: meta.icon,
|
|
2302
|
+
label: taxonomy.labelOf(kind, props),
|
|
2303
|
+
onClick: null
|
|
2304
|
+
});
|
|
2305
|
+
} else if (kind !== topNav) {
|
|
2306
|
+
segs.push({
|
|
2307
|
+
icon: meta.icon,
|
|
2308
|
+
label: meta.label,
|
|
2309
|
+
onClick: null
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
} else if (hasEntity) {
|
|
2313
|
+
segs.push({
|
|
2314
|
+
icon: "description",
|
|
2315
|
+
label: props.label || props.id,
|
|
2316
|
+
onClick: null
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
return segs;
|
|
2320
|
+
}
|
|
2321
|
+
function _renderInto(container, segments) {
|
|
2322
|
+
container.innerHTML = "";
|
|
2323
|
+
if (!segments?.length) return;
|
|
2324
|
+
const ol = document.createElement("ol");
|
|
2325
|
+
ol.className = "twm-topbar-breadcrumb__list";
|
|
2326
|
+
segments.forEach((seg, i) => {
|
|
2327
|
+
const li = document.createElement("li");
|
|
2328
|
+
li.className = "twm-topbar-breadcrumb__item";
|
|
2329
|
+
const isLast = i === segments.length - 1;
|
|
2330
|
+
if (isLast || !seg.onClick) {
|
|
2331
|
+
li.classList.add("twm-topbar-breadcrumb__item--current");
|
|
2332
|
+
li.innerHTML = `
|
|
2333
|
+
<span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
|
|
2334
|
+
<span class="twm-topbar-breadcrumb__current">${_esc5(seg.label)}</span>
|
|
2335
|
+
`;
|
|
2336
|
+
} else {
|
|
2337
|
+
const btn = document.createElement("button");
|
|
2338
|
+
btn.className = "twm-topbar-breadcrumb__link";
|
|
2339
|
+
btn.type = "button";
|
|
2340
|
+
btn.innerHTML = `
|
|
2341
|
+
<span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
|
|
2342
|
+
<span>${_esc5(seg.label)}</span>
|
|
2343
|
+
`;
|
|
2344
|
+
btn.addEventListener("click", () => {
|
|
2345
|
+
btn.classList.add("twm-tile-breadcrumb__link--flash");
|
|
2346
|
+
setTimeout(() => {
|
|
2347
|
+
btn.classList.remove("twm-tile-breadcrumb__link--flash");
|
|
2348
|
+
}, 180);
|
|
2349
|
+
try {
|
|
2350
|
+
seg.onClick();
|
|
2351
|
+
} catch (e) {
|
|
2352
|
+
console.error("[breadcrumb] segment click threw", e);
|
|
2353
|
+
}
|
|
2354
|
+
});
|
|
2355
|
+
li.appendChild(btn);
|
|
2356
|
+
const sep = document.createElement("span");
|
|
2357
|
+
sep.className = "twm-topbar-breadcrumb__separator";
|
|
2358
|
+
sep.textContent = "\u203A";
|
|
2359
|
+
li.appendChild(sep);
|
|
2360
|
+
}
|
|
2361
|
+
ol.appendChild(li);
|
|
2362
|
+
});
|
|
2363
|
+
container.appendChild(ol);
|
|
2364
|
+
}
|
|
2365
|
+
function _esc5(s) {
|
|
2366
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
2367
|
+
"&": "&",
|
|
2368
|
+
"<": "<",
|
|
2369
|
+
">": ">",
|
|
2370
|
+
'"': """,
|
|
2371
|
+
"'": "'"
|
|
2372
|
+
})[c]);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/tiling/page_factory.js
|
|
2376
|
+
function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } = {}) {
|
|
2377
|
+
const projectChanged = events?.projectChanged || null;
|
|
2378
|
+
const makeShim = (ctx) => ({
|
|
2379
|
+
// Route through openFromContext so navigation triggered from a
|
|
2380
|
+
// split tile / window stays in that container.
|
|
2381
|
+
openTab: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.openFromContext(ctx, k, { id: entityId, label, icon, subTab }),
|
|
2382
|
+
// Open the same content as a NEW TAB in the current tile.
|
|
2383
|
+
openInTab: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.openInTabFromContext(ctx, k, { id: entityId, label, icon, subTab }),
|
|
2384
|
+
// Open the same content in a fresh managed window.
|
|
2385
|
+
openInWindow: ({ kind: k, entityId, label, icon, subTab }) => ctx.wm?.navigate(k, { id: entityId, label, icon, subTab }, { ctx, dest: "window" }),
|
|
2386
|
+
// Persist editor sub-state into this tile's WM tab props.
|
|
2387
|
+
updateProps: (patch) => {
|
|
2388
|
+
try {
|
|
2389
|
+
ctx.wm?.updateActiveTabProps?.(ctx.leafId, patch);
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
console.warn("[shim] updateProps failed", err);
|
|
2392
|
+
}
|
|
2393
|
+
},
|
|
2394
|
+
// Close just THIS tab (not the whole tile); falls back to closing
|
|
2395
|
+
// the tile when it's the last tab. Also broadcasts so sidebars /
|
|
2396
|
+
// landings / nav refresh against the mutated project state.
|
|
2397
|
+
closeTab: (_id) => {
|
|
2398
|
+
if (projectChanged) {
|
|
2399
|
+
try {
|
|
2400
|
+
eventBus?.emit?.(projectChanged, { source: "twm-tab-close" });
|
|
2401
|
+
} catch {
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
try {
|
|
2405
|
+
ctx.wm?.closeActiveTab?.(ctx.leafId);
|
|
2406
|
+
} catch (err) {
|
|
2407
|
+
console.warn("[shim] closeTab failed", err);
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2410
|
+
// Non-closing mutations (Save/New/Rename) broadcast so listeners refresh.
|
|
2411
|
+
notifyChanged: (domain) => {
|
|
2412
|
+
if (!projectChanged) return;
|
|
2413
|
+
try {
|
|
2414
|
+
eventBus?.emit?.(
|
|
2415
|
+
projectChanged,
|
|
2416
|
+
{ source: "tab-mutation", domain: domain || null }
|
|
2417
|
+
);
|
|
2418
|
+
} catch {
|
|
2419
|
+
}
|
|
2420
|
+
},
|
|
2421
|
+
registerProvider: () => {
|
|
2422
|
+
},
|
|
2423
|
+
getActiveTab: () => null
|
|
2424
|
+
});
|
|
2425
|
+
const mountTabBody = (factory, contentSlot, props, ctx) => {
|
|
2426
|
+
const shim = makeShim(ctx);
|
|
2427
|
+
const tab = factory(contentSlot, props?.id ?? props?.entityId ?? null, {
|
|
2428
|
+
logger: null,
|
|
2429
|
+
eventBus,
|
|
2430
|
+
workspaceTabs: shim,
|
|
2431
|
+
wm: ctx.wm,
|
|
2432
|
+
leafId: ctx.leafId,
|
|
2433
|
+
windowId: ctx.windowId
|
|
2434
|
+
});
|
|
2435
|
+
const hideLoading = makeLoadingOverlay(contentSlot);
|
|
2436
|
+
try {
|
|
2437
|
+
const ret = tab?.mount?.(props);
|
|
2438
|
+
if (ret && typeof ret.then === "function") {
|
|
2439
|
+
ret.then(hideLoading, (err) => {
|
|
2440
|
+
console.error("[page] mount failed", err);
|
|
2441
|
+
hideLoading();
|
|
2442
|
+
});
|
|
2443
|
+
} else {
|
|
2444
|
+
requestAnimationFrame(hideLoading);
|
|
2445
|
+
}
|
|
2446
|
+
} catch (err) {
|
|
2447
|
+
console.error("[page] mount failed", err);
|
|
2448
|
+
hideLoading();
|
|
2449
|
+
}
|
|
2450
|
+
const listeners = {};
|
|
2451
|
+
if (typeof tab?.refresh === "function" && eventBus?.on) {
|
|
2452
|
+
for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
|
|
2453
|
+
const fn = () => {
|
|
2454
|
+
try {
|
|
2455
|
+
tab.refresh(reason);
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
console.warn("[page] refresh failed", busEvent, err);
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
listeners[busEvent] = fn;
|
|
2461
|
+
eventBus.on(busEvent, fn);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
return () => {
|
|
2465
|
+
for (const [n, fn] of Object.entries(listeners)) {
|
|
2466
|
+
try {
|
|
2467
|
+
eventBus?.off?.(n, fn);
|
|
2468
|
+
} catch {
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
try {
|
|
2472
|
+
tab?.dispose?.();
|
|
2473
|
+
tab?.unmount?.();
|
|
2474
|
+
} catch {
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
};
|
|
2478
|
+
const tabFactory = (kind, factory) => (host, props, ctx) => {
|
|
2479
|
+
host.classList.add("twm-page-shell");
|
|
2480
|
+
host.innerHTML = "";
|
|
2481
|
+
const contentSlot = document.createElement("div");
|
|
2482
|
+
contentSlot.className = "twm-page-shell__content";
|
|
2483
|
+
contentSlot.tabIndex = -1;
|
|
2484
|
+
let crumb = null;
|
|
2485
|
+
if (!ctx?.windowId) {
|
|
2486
|
+
const breadcrumbSlot = document.createElement("div");
|
|
2487
|
+
breadcrumbSlot.className = "twm-page-shell__breadcrumb";
|
|
2488
|
+
host.appendChild(breadcrumbSlot);
|
|
2489
|
+
crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
|
|
2490
|
+
breadcrumbSlot.appendChild(crumb.el);
|
|
2491
|
+
}
|
|
2492
|
+
host.appendChild(contentSlot);
|
|
2493
|
+
const teardown = mountTabBody(factory, contentSlot, props, ctx);
|
|
2494
|
+
return {
|
|
2495
|
+
destroy: () => {
|
|
2496
|
+
teardown();
|
|
2497
|
+
try {
|
|
2498
|
+
crumb?.destroy();
|
|
2499
|
+
} catch {
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
};
|
|
2504
|
+
const bareTabFactory = (kind, factory) => (host, props, ctx) => {
|
|
2505
|
+
host.classList.add("twm-page-shell");
|
|
2506
|
+
host.innerHTML = "";
|
|
2507
|
+
const contentSlot = document.createElement("div");
|
|
2508
|
+
contentSlot.className = "twm-page-shell__content";
|
|
2509
|
+
contentSlot.tabIndex = -1;
|
|
2510
|
+
host.appendChild(contentSlot);
|
|
2511
|
+
return { destroy: mountTabBody(factory, contentSlot, props, ctx) };
|
|
2512
|
+
};
|
|
2513
|
+
const stubPageFactory = (kind, render) => (host, props, ctx) => {
|
|
2514
|
+
host.classList.add("twm-page-shell");
|
|
2515
|
+
host.innerHTML = "";
|
|
2516
|
+
const breadcrumbSlot = document.createElement("div");
|
|
2517
|
+
breadcrumbSlot.className = "twm-page-shell__breadcrumb";
|
|
2518
|
+
const contentSlot = document.createElement("div");
|
|
2519
|
+
contentSlot.className = "twm-page-shell__content";
|
|
2520
|
+
contentSlot.tabIndex = -1;
|
|
2521
|
+
host.appendChild(breadcrumbSlot);
|
|
2522
|
+
host.appendChild(contentSlot);
|
|
2523
|
+
const crumb = mountTileBreadcrumb(kind, props, { ...ctx, eventBus });
|
|
2524
|
+
breadcrumbSlot.appendChild(crumb.el);
|
|
2525
|
+
const hideLoading = makeLoadingOverlay(contentSlot);
|
|
2526
|
+
const ret = render(contentSlot, props, ctx) || {};
|
|
2527
|
+
if (ret.ready && typeof ret.ready.then === "function") {
|
|
2528
|
+
ret.ready.then(hideLoading, hideLoading);
|
|
2529
|
+
} else {
|
|
2530
|
+
requestAnimationFrame(hideLoading);
|
|
2531
|
+
}
|
|
2532
|
+
return {
|
|
2533
|
+
title: ret.title,
|
|
2534
|
+
destroy: () => {
|
|
2535
|
+
try {
|
|
2536
|
+
crumb.destroy();
|
|
2537
|
+
} catch {
|
|
2538
|
+
}
|
|
2539
|
+
ret.destroy?.();
|
|
2540
|
+
}
|
|
2541
|
+
};
|
|
2542
|
+
};
|
|
2543
|
+
return Object.freeze({
|
|
2544
|
+
makeShim,
|
|
2545
|
+
mountTabBody,
|
|
2546
|
+
tabFactory,
|
|
2547
|
+
bareTabFactory,
|
|
2548
|
+
stubPageFactory
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// src/tiling/tile_renderer.js
|
|
2553
|
+
var SPLITTER_PX = 4;
|
|
2554
|
+
var TileRenderer = class {
|
|
2555
|
+
constructor({ root, tree, content, ctx, onFocusChange }) {
|
|
2556
|
+
if (!content || typeof content.mount !== "function") {
|
|
2557
|
+
throw new Error("TileRenderer: a content registry is required");
|
|
2558
|
+
}
|
|
2559
|
+
this.root = root;
|
|
2560
|
+
this.tree = tree;
|
|
2561
|
+
this.content = content;
|
|
2562
|
+
this.ctx = ctx || {};
|
|
2563
|
+
this.onFocusChange = onFocusChange || (() => {
|
|
2564
|
+
});
|
|
2565
|
+
this._leafCache = /* @__PURE__ */ new Map();
|
|
2566
|
+
this._drag = null;
|
|
2567
|
+
this.root.classList.add("twm-root");
|
|
2568
|
+
this.root.addEventListener("mousedown", this._onMouseDown.bind(this));
|
|
2569
|
+
}
|
|
2570
|
+
render() {
|
|
2571
|
+
const savedScrolls = /* @__PURE__ */ new Map();
|
|
2572
|
+
for (const [leafId, entry] of this._leafCache.entries()) {
|
|
2573
|
+
const snap = [];
|
|
2574
|
+
entry.wrapEl.querySelectorAll("*").forEach((el) => {
|
|
2575
|
+
if (el.scrollTop > 0 || el.scrollLeft > 0) {
|
|
2576
|
+
snap.push({ el, top: el.scrollTop, left: el.scrollLeft });
|
|
2577
|
+
}
|
|
2578
|
+
});
|
|
2579
|
+
savedScrolls.set(leafId, snap);
|
|
2580
|
+
entry.wrapEl.remove();
|
|
2581
|
+
}
|
|
2582
|
+
this.root.innerHTML = "";
|
|
2583
|
+
const tree = this.tree;
|
|
2584
|
+
if (!tree.rootId) {
|
|
2585
|
+
const empty = document.createElement("div");
|
|
2586
|
+
empty.className = "twm-empty";
|
|
2587
|
+
empty.textContent = "Empty desktop \u2014 Ctrl+K to open something.";
|
|
2588
|
+
this.root.appendChild(empty);
|
|
2589
|
+
this._cleanCache(/* @__PURE__ */ new Set());
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
const liveLeafIds = /* @__PURE__ */ new Set();
|
|
2593
|
+
this._mount(tree.rootId, this.root, liveLeafIds);
|
|
2594
|
+
this._cleanCache(liveLeafIds);
|
|
2595
|
+
this._updateFocusClasses();
|
|
2596
|
+
const restore = () => {
|
|
2597
|
+
for (const [leafId, snap] of savedScrolls) {
|
|
2598
|
+
if (!this._leafCache.has(leafId)) continue;
|
|
2599
|
+
for (const { el, top, left } of snap) {
|
|
2600
|
+
if (el.isConnected) {
|
|
2601
|
+
el.scrollTop = top;
|
|
2602
|
+
el.scrollLeft = left;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
restore();
|
|
2608
|
+
requestAnimationFrame(restore);
|
|
2609
|
+
}
|
|
2610
|
+
_cleanCache(liveSet) {
|
|
2611
|
+
for (const [leafId, entry] of [...this._leafCache.entries()]) {
|
|
2612
|
+
if (!liveSet.has(leafId)) {
|
|
2613
|
+
try {
|
|
2614
|
+
entry.content?.destroy?.();
|
|
2615
|
+
} catch (_) {
|
|
2616
|
+
}
|
|
2617
|
+
entry.wrapEl.remove();
|
|
2618
|
+
this._leafCache.delete(leafId);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
_mount(nodeId, parentEl, liveSet) {
|
|
2623
|
+
const node = this.tree.get(nodeId);
|
|
2624
|
+
if (!node) return;
|
|
2625
|
+
if (node.kind === "leaf") {
|
|
2626
|
+
liveSet.add(node.id);
|
|
2627
|
+
const el = this._leafEl(node);
|
|
2628
|
+
parentEl.appendChild(el);
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const split = document.createElement("div");
|
|
2632
|
+
split.className = `twm-split twm-split--${node.dir}`;
|
|
2633
|
+
split.dataset.splitId = node.id;
|
|
2634
|
+
parentEl.appendChild(split);
|
|
2635
|
+
const total = node.sizes.reduce((a, b) => a + b, 0) || node.children.length;
|
|
2636
|
+
node.children.forEach((cid, i) => {
|
|
2637
|
+
const slot = document.createElement("div");
|
|
2638
|
+
slot.className = "twm-slot";
|
|
2639
|
+
const frac = (node.sizes[i] || 1) / total;
|
|
2640
|
+
slot.style.flex = `${frac} ${frac} 0`;
|
|
2641
|
+
split.appendChild(slot);
|
|
2642
|
+
this._mount(cid, slot, liveSet);
|
|
2643
|
+
if (i < node.children.length - 1) {
|
|
2644
|
+
const splitter = document.createElement("div");
|
|
2645
|
+
splitter.className = `twm-splitter twm-splitter--${node.dir}`;
|
|
2646
|
+
splitter.dataset.splitId = node.id;
|
|
2647
|
+
splitter.dataset.slotIdx = String(i);
|
|
2648
|
+
split.appendChild(splitter);
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
_leafEl(leaf) {
|
|
2653
|
+
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 tabFingerprint = (leaf.tabs || []).map((t) => `${t.kind}::${JSON.stringify(t.props || {})}`).join("|") + `#${leaf.activeTabIdx || 0}`;
|
|
2655
|
+
const kindKey = leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
|
|
2656
|
+
let entry = this._leafCache.get(leaf.id);
|
|
2657
|
+
if (entry && entry.kindKey === kindKey) {
|
|
2658
|
+
entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
|
|
2659
|
+
return entry.wrapEl;
|
|
2660
|
+
}
|
|
2661
|
+
if (entry) {
|
|
2662
|
+
try {
|
|
2663
|
+
entry.content?.destroy?.();
|
|
2664
|
+
} catch (_) {
|
|
2665
|
+
}
|
|
2666
|
+
entry.wrapEl.remove();
|
|
2667
|
+
}
|
|
2668
|
+
const wrap = document.createElement("div");
|
|
2669
|
+
wrap.className = "twm-leaf";
|
|
2670
|
+
wrap.dataset.leafId = leaf.id;
|
|
2671
|
+
const chrome = document.createElement("div");
|
|
2672
|
+
chrome.className = "twm-leaf__chrome";
|
|
2673
|
+
const title = document.createElement("span");
|
|
2674
|
+
title.className = "twm-leaf__title";
|
|
2675
|
+
title.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
|
|
2676
|
+
const actions = document.createElement("span");
|
|
2677
|
+
actions.className = "twm-leaf__actions";
|
|
2678
|
+
const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
|
|
2679
|
+
actions.innerHTML = `
|
|
2680
|
+
${isPanel ? "" : `
|
|
2681
|
+
<button class="twm-leaf__btn" data-action="split-h" title="Split horizontally (Alt+H)">
|
|
2682
|
+
<span class="material-symbols-outlined">splitscreen_vertical_add</span>
|
|
2683
|
+
</button>
|
|
2684
|
+
<button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
|
|
2685
|
+
<span class="material-symbols-outlined">splitscreen_add</span>
|
|
2686
|
+
</button>
|
|
2687
|
+
<button class="twm-leaf__btn" data-action="promote" title="Promote to window (Alt+F)">
|
|
2688
|
+
<span class="material-symbols-outlined">open_in_new</span>
|
|
2689
|
+
</button>`}
|
|
2690
|
+
<button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
|
|
2691
|
+
<span class="material-symbols-outlined">close</span>
|
|
2692
|
+
</button>
|
|
2693
|
+
`;
|
|
2694
|
+
chrome.append(title, actions);
|
|
2695
|
+
const body = document.createElement("div");
|
|
2696
|
+
body.className = "twm-leaf__body";
|
|
2697
|
+
body.tabIndex = -1;
|
|
2698
|
+
const tabBar = document.createElement("div");
|
|
2699
|
+
tabBar.className = "twm-leaf__tabbar";
|
|
2700
|
+
if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
|
|
2701
|
+
tabBar.classList.add("twm-leaf__tabbar--hidden");
|
|
2702
|
+
}
|
|
2703
|
+
wrap.append(chrome, body, tabBar);
|
|
2704
|
+
wrap.addEventListener("mousedown", (e) => {
|
|
2705
|
+
if (e.target.closest(".twm-splitter")) return;
|
|
2706
|
+
this.tree.focus(leaf.id);
|
|
2707
|
+
this._updateFocusClasses();
|
|
2708
|
+
this.onFocusChange(leaf.id);
|
|
2709
|
+
const tgt = e.target;
|
|
2710
|
+
const focusable = tgt.closest?.(
|
|
2711
|
+
'input, textarea, select, button, a, [contenteditable="true"], [tabindex]'
|
|
2712
|
+
);
|
|
2713
|
+
if (!focusable || !body.contains(focusable)) {
|
|
2714
|
+
setTimeout(() => {
|
|
2715
|
+
const ps = body.querySelector(".twm-page-shell__content") ?? body;
|
|
2716
|
+
if (ps.tabIndex == null || ps.tabIndex < -1) ps.tabIndex = -1;
|
|
2717
|
+
ps.focus({ preventScroll: true });
|
|
2718
|
+
}, 0);
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
chrome.addEventListener("contextmenu", (e) => {
|
|
2722
|
+
e.preventDefault();
|
|
2723
|
+
this.tree.focus(leaf.id);
|
|
2724
|
+
this._updateFocusClasses();
|
|
2725
|
+
this.onFocusChange(leaf.id);
|
|
2726
|
+
this.ctx.onTileContextMenu?.(leaf.id, e.clientX, e.clientY);
|
|
2727
|
+
});
|
|
2728
|
+
actions.addEventListener("click", (e) => {
|
|
2729
|
+
const btn = e.target.closest("button");
|
|
2730
|
+
if (!btn) return;
|
|
2731
|
+
e.stopPropagation();
|
|
2732
|
+
this.ctx.onLeafAction?.(leaf.id, btn.dataset.action);
|
|
2733
|
+
});
|
|
2734
|
+
let content = {};
|
|
2735
|
+
if (leaf.content) {
|
|
2736
|
+
const leafCtx = { ...this.ctx, leafId: leaf.id };
|
|
2737
|
+
const activeProps = activeTab?.props ?? leaf.content.props;
|
|
2738
|
+
content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
|
|
2739
|
+
if (content.title) title.textContent = content.title;
|
|
2740
|
+
} else {
|
|
2741
|
+
body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
|
|
2742
|
+
}
|
|
2743
|
+
entry = {
|
|
2744
|
+
wrapEl: wrap,
|
|
2745
|
+
bodyEl: body,
|
|
2746
|
+
chromeEl: chrome,
|
|
2747
|
+
titleEl: title,
|
|
2748
|
+
content,
|
|
2749
|
+
kindKey,
|
|
2750
|
+
tabBarEl: tabBar
|
|
2751
|
+
};
|
|
2752
|
+
this._leafCache.set(leaf.id, entry);
|
|
2753
|
+
this._renderTabBar(leaf, entry);
|
|
2754
|
+
return wrap;
|
|
2755
|
+
}
|
|
2756
|
+
/** Paint a leaf's bottom tab strip. The strip is hidden for
|
|
2757
|
+
* single-tab leaves (the common case) so existing layouts read as
|
|
2758
|
+
* identical to today. Hamburger button at the start, then one
|
|
2759
|
+
* trapezoid-shaped tab per stored tab spec. */
|
|
2760
|
+
_renderTabBar(leaf, entry) {
|
|
2761
|
+
const bar = entry.tabBarEl;
|
|
2762
|
+
if (!bar) return;
|
|
2763
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
2764
|
+
if (tabs.length <= 1) {
|
|
2765
|
+
bar.classList.add("twm-leaf__tabbar--hidden");
|
|
2766
|
+
bar.innerHTML = "";
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
bar.classList.remove("twm-leaf__tabbar--hidden");
|
|
2770
|
+
const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
2771
|
+
bar.innerHTML = `
|
|
2772
|
+
<button type="button" class="twm-leaf__tab-hamburger"
|
|
2773
|
+
data-action="tab-menu"
|
|
2774
|
+
title="Open in new tab from this page's content">
|
|
2775
|
+
<span class="material-symbols-outlined">menu</span>
|
|
2776
|
+
</button>
|
|
2777
|
+
<ol class="twm-leaf__tabs" role="tablist">
|
|
2778
|
+
${tabs.map((t, i) => `
|
|
2779
|
+
<li class="twm-leaf__tab${i === activeIdx ? " twm-leaf__tab--on" : ""}"
|
|
2780
|
+
role="tab" data-tab-idx="${i}"
|
|
2781
|
+
title="${_esc6(t.title || t.kind)}"
|
|
2782
|
+
draggable="true">
|
|
2783
|
+
<span class="twm-leaf__tab-label">${_esc6(t.title || t.kind)}</span>
|
|
2784
|
+
<button type="button" class="twm-leaf__tab-close"
|
|
2785
|
+
data-action="tab-close" data-tab-idx="${i}"
|
|
2786
|
+
title="Close this tab"
|
|
2787
|
+
aria-label="Close tab">
|
|
2788
|
+
<span class="material-symbols-outlined">close</span>
|
|
2789
|
+
</button>
|
|
2790
|
+
</li>
|
|
2791
|
+
`).join("")}
|
|
2792
|
+
</ol>
|
|
2793
|
+
`;
|
|
2794
|
+
bar.querySelectorAll(".twm-leaf__tab").forEach((li) => {
|
|
2795
|
+
li.addEventListener("click", (ev) => {
|
|
2796
|
+
if (ev.target.closest('[data-action="tab-close"]')) return;
|
|
2797
|
+
const idx = Number(li.dataset.tabIdx);
|
|
2798
|
+
this.ctx.onLeafTabAction?.(leaf.id, "switch", { idx });
|
|
2799
|
+
});
|
|
2800
|
+
li.addEventListener("contextmenu", (ev) => {
|
|
2801
|
+
ev.preventDefault();
|
|
2802
|
+
ev.stopPropagation();
|
|
2803
|
+
const idx = Number(li.dataset.tabIdx);
|
|
2804
|
+
this.ctx.onLeafTabAction?.(
|
|
2805
|
+
leaf.id,
|
|
2806
|
+
"menu",
|
|
2807
|
+
{ idx, x: ev.clientX, y: ev.clientY }
|
|
2808
|
+
);
|
|
2809
|
+
});
|
|
2810
|
+
});
|
|
2811
|
+
bar.querySelectorAll('[data-action="tab-close"]').forEach((btn) => {
|
|
2812
|
+
btn.addEventListener("click", (ev) => {
|
|
2813
|
+
ev.preventDefault();
|
|
2814
|
+
ev.stopPropagation();
|
|
2815
|
+
const idx = Number(btn.dataset.tabIdx);
|
|
2816
|
+
this.ctx.onLeafTabAction?.(leaf.id, "close", { idx });
|
|
2817
|
+
});
|
|
2818
|
+
});
|
|
2819
|
+
bar.querySelector('[data-action="tab-menu"]')?.addEventListener("click", (ev) => {
|
|
2820
|
+
ev.preventDefault();
|
|
2821
|
+
const r = ev.currentTarget.getBoundingClientRect();
|
|
2822
|
+
this.ctx.onLeafTabAction?.(
|
|
2823
|
+
leaf.id,
|
|
2824
|
+
"open-menu",
|
|
2825
|
+
{ x: r.left, y: r.top }
|
|
2826
|
+
);
|
|
2827
|
+
});
|
|
2828
|
+
let dragFromIdx = null;
|
|
2829
|
+
bar.querySelectorAll(".twm-leaf__tab").forEach((li) => {
|
|
2830
|
+
li.addEventListener("dragstart", (ev) => {
|
|
2831
|
+
dragFromIdx = Number(li.dataset.tabIdx);
|
|
2832
|
+
ev.dataTransfer.effectAllowed = "move";
|
|
2833
|
+
try {
|
|
2834
|
+
ev.dataTransfer.setData("text/plain", String(dragFromIdx));
|
|
2835
|
+
} catch {
|
|
2836
|
+
}
|
|
2837
|
+
});
|
|
2838
|
+
li.addEventListener("dragover", (ev) => {
|
|
2839
|
+
if (dragFromIdx == null) return;
|
|
2840
|
+
ev.preventDefault();
|
|
2841
|
+
ev.dataTransfer.dropEffect = "move";
|
|
2842
|
+
});
|
|
2843
|
+
li.addEventListener("drop", (ev) => {
|
|
2844
|
+
if (dragFromIdx == null) return;
|
|
2845
|
+
ev.preventDefault();
|
|
2846
|
+
const toIdx = Number(li.dataset.tabIdx);
|
|
2847
|
+
if (toIdx !== dragFromIdx) {
|
|
2848
|
+
this.ctx.onLeafTabAction?.(
|
|
2849
|
+
leaf.id,
|
|
2850
|
+
"move",
|
|
2851
|
+
{ from: dragFromIdx, to: toIdx }
|
|
2852
|
+
);
|
|
2853
|
+
}
|
|
2854
|
+
dragFromIdx = null;
|
|
2855
|
+
});
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
_updateFocusClasses() {
|
|
2859
|
+
const focused = this.tree.focusedLeafId;
|
|
2860
|
+
for (const [leafId, entry] of this._leafCache) {
|
|
2861
|
+
entry.wrapEl.classList.toggle("twm-leaf--focused", leafId === focused);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
/** The `.twm-leaf` wrap element for a leaf id, or null if it isn't
|
|
2865
|
+
* currently rendered. Used by the panel-key router to resolve which
|
|
2866
|
+
* DOM subtree owns keyboard input. */
|
|
2867
|
+
leafEl(leafId) {
|
|
2868
|
+
return this._leafCache.get(leafId)?.wrapEl || null;
|
|
2869
|
+
}
|
|
2870
|
+
// ── Drag-resize ────────────────────────────────────────────────
|
|
2871
|
+
_onMouseDown(e) {
|
|
2872
|
+
const splitter = e.target.closest(".twm-splitter");
|
|
2873
|
+
if (!splitter) return;
|
|
2874
|
+
e.preventDefault();
|
|
2875
|
+
const splitId = splitter.dataset.splitId;
|
|
2876
|
+
const slotIdx = Number(splitter.dataset.slotIdx);
|
|
2877
|
+
const split = this.tree.get(splitId);
|
|
2878
|
+
if (!split) return;
|
|
2879
|
+
const containerEl = splitter.parentElement;
|
|
2880
|
+
const horizontal = split.dir === "h";
|
|
2881
|
+
const rect = containerEl.getBoundingClientRect();
|
|
2882
|
+
const totalPx = horizontal ? rect.width : rect.height;
|
|
2883
|
+
this._drag = {
|
|
2884
|
+
splitId,
|
|
2885
|
+
slotIdx,
|
|
2886
|
+
horizontal,
|
|
2887
|
+
totalPx,
|
|
2888
|
+
startSizes: split.sizes.slice(),
|
|
2889
|
+
startPos: horizontal ? e.clientX : e.clientY,
|
|
2890
|
+
split
|
|
2891
|
+
};
|
|
2892
|
+
document.body.classList.add("twm-dragging");
|
|
2893
|
+
const move = (ev) => this._onMouseMove(ev);
|
|
2894
|
+
const up = (ev) => {
|
|
2895
|
+
this._onMouseUp(ev);
|
|
2896
|
+
document.removeEventListener("mousemove", move);
|
|
2897
|
+
document.removeEventListener("mouseup", up);
|
|
2898
|
+
};
|
|
2899
|
+
document.addEventListener("mousemove", move);
|
|
2900
|
+
document.addEventListener("mouseup", up);
|
|
2901
|
+
}
|
|
2902
|
+
_onMouseMove(e) {
|
|
2903
|
+
if (!this._drag) return;
|
|
2904
|
+
const { horizontal, totalPx, startSizes, startPos, slotIdx, split } = this._drag;
|
|
2905
|
+
const delta = (horizontal ? e.clientX : e.clientY) - startPos;
|
|
2906
|
+
const totalSize = startSizes.reduce((a, b) => a + b, 0);
|
|
2907
|
+
const px = totalPx - SPLITTER_PX * (split.children.length - 1);
|
|
2908
|
+
if (px <= 0) return;
|
|
2909
|
+
const deltaFrac = delta / px * totalSize;
|
|
2910
|
+
const minFrac = totalSize * 0.05;
|
|
2911
|
+
const newSizes = startSizes.slice();
|
|
2912
|
+
newSizes[slotIdx] = Math.max(minFrac, startSizes[slotIdx] + deltaFrac);
|
|
2913
|
+
newSizes[slotIdx + 1] = Math.max(minFrac, startSizes[slotIdx + 1] - deltaFrac);
|
|
2914
|
+
const sum = newSizes.reduce((a, b) => a + b, 0);
|
|
2915
|
+
const scale = totalSize / sum;
|
|
2916
|
+
for (let i = 0; i < newSizes.length; i++) newSizes[i] *= scale;
|
|
2917
|
+
this.tree.setSplitSizes(split.id, newSizes);
|
|
2918
|
+
const containerEl = document.querySelector(`.twm-split[data-split-id="${split.id}"]`);
|
|
2919
|
+
if (!containerEl) return;
|
|
2920
|
+
const slots = containerEl.querySelectorAll(":scope > .twm-slot");
|
|
2921
|
+
slots.forEach((slot, i) => {
|
|
2922
|
+
const frac = newSizes[i] / totalSize;
|
|
2923
|
+
slot.style.flex = `${frac} ${frac} 0`;
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
_onMouseUp() {
|
|
2927
|
+
this._drag = null;
|
|
2928
|
+
document.body.classList.remove("twm-dragging");
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
function _esc6(s) {
|
|
2932
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
2933
|
+
"&": "&",
|
|
2934
|
+
"<": "<",
|
|
2935
|
+
">": ">",
|
|
2936
|
+
'"': """,
|
|
2937
|
+
"'": "'"
|
|
2938
|
+
})[c]);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// src/tiling/wm.js
|
|
2942
|
+
var PANEL_KINDS = /* @__PURE__ */ new Set(["panel:left", "panel:right", "panel:bottom"]);
|
|
2943
|
+
var PANEL_TITLES = {
|
|
2944
|
+
left: "Navigator",
|
|
2945
|
+
right: "Inspector",
|
|
2946
|
+
bottom: "Console"
|
|
2947
|
+
};
|
|
2948
|
+
var WindowManager = class {
|
|
2949
|
+
constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
|
|
2950
|
+
if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
|
|
2951
|
+
if (!content || typeof content.mount !== "function") {
|
|
2952
|
+
throw new Error("WindowManager: a content registry is required (createContentRegistry / createShell owns it)");
|
|
2953
|
+
}
|
|
2954
|
+
this.rootEl = rootEl;
|
|
2955
|
+
this.content = content;
|
|
2956
|
+
this.api = api || null;
|
|
2957
|
+
this.host = host || null;
|
|
2958
|
+
this.taxonomy = taxonomy;
|
|
2959
|
+
this.events = events || {};
|
|
2960
|
+
this.ctx = ctx || {};
|
|
2961
|
+
this.eventBus = eventBus || null;
|
|
2962
|
+
this.onChange = onChange || (() => {
|
|
2963
|
+
});
|
|
2964
|
+
this._rootLeaf = () => {
|
|
2965
|
+
const kind = this.taxonomy.root;
|
|
2966
|
+
return {
|
|
2967
|
+
content: { kind, props: {} },
|
|
2968
|
+
title: this.taxonomy.meta(kind)?.label || kind
|
|
2969
|
+
};
|
|
2970
|
+
};
|
|
2971
|
+
this.desktops = new DesktopManager({ seed: this._rootLeaf });
|
|
2972
|
+
this.renderer = new TileRenderer({
|
|
2973
|
+
root: rootEl,
|
|
2974
|
+
tree: this.desktops.active().tree,
|
|
2975
|
+
content,
|
|
2976
|
+
ctx: {
|
|
2977
|
+
...this.ctx,
|
|
2978
|
+
wm: this,
|
|
2979
|
+
onLeafAction: (leafId, action) => this._leafAction(leafId, action),
|
|
2980
|
+
onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
|
|
2981
|
+
},
|
|
2982
|
+
onFocusChange: () => this._notifyChange()
|
|
2983
|
+
});
|
|
2984
|
+
this._persistTimer = null;
|
|
2985
|
+
this._windowToLeaf = /* @__PURE__ */ new Map();
|
|
2986
|
+
this.panelKeys = installPanelKeyRouter(this);
|
|
2987
|
+
const renamedEvent = this.events.entityRenamed;
|
|
2988
|
+
if (renamedEvent) {
|
|
2989
|
+
this.eventBus?.on?.(renamedEvent, ({ kind, entityId, label } = {}) => {
|
|
2990
|
+
if (!kind || !entityId || !label) return;
|
|
2991
|
+
let touched = false;
|
|
2992
|
+
for (const d of this.desktops.desktops) {
|
|
2993
|
+
if (d.tree.renameMatchingTabs?.(kind, entityId, label)) {
|
|
2994
|
+
touched = true;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
if (touched) {
|
|
2998
|
+
this.renderer.render();
|
|
2999
|
+
this._persist();
|
|
3000
|
+
}
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
/** Emit on bus + call onChange. Use this instead of the bare callback
|
|
3005
|
+
* so other surfaces (palette, top-bar toggles, page shortcuts) can
|
|
3006
|
+
* subscribe through the existing event system. */
|
|
3007
|
+
_notifyChange(reason = null) {
|
|
3008
|
+
try {
|
|
3009
|
+
this.onChange(reason);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
console.error("[wm] onChange threw", err);
|
|
3012
|
+
}
|
|
3013
|
+
try {
|
|
3014
|
+
this.eventBus?.emit?.("wm:changed", { reason, wm: this });
|
|
3015
|
+
} catch (err) {
|
|
3016
|
+
console.warn("[wm] event emit failed", err);
|
|
3017
|
+
}
|
|
3018
|
+
try {
|
|
3019
|
+
const tree = this._tree();
|
|
3020
|
+
const id = tree.primaryLeafId();
|
|
3021
|
+
const leaf = id ? tree.get(id) : null;
|
|
3022
|
+
const kind = leaf?.content?.kind;
|
|
3023
|
+
if (kind && kind !== "window-placeholder") {
|
|
3024
|
+
this.eventBus?.emit?.("workspace:tabs:activated", {
|
|
3025
|
+
kind,
|
|
3026
|
+
entityId: leaf.content.props?.id ?? null,
|
|
3027
|
+
label: leaf.title,
|
|
3028
|
+
from: reason || "wm"
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
} catch (_) {
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
// ── Persistence ─────────────────────────────────────────────────
|
|
3035
|
+
async load() {
|
|
3036
|
+
const blob = await loadDesktops(this.host?.state);
|
|
3037
|
+
if (blob) {
|
|
3038
|
+
this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
|
|
3039
|
+
this.renderer.tree = this.desktops.active().tree;
|
|
3040
|
+
}
|
|
3041
|
+
for (const d of this.desktops.desktops) {
|
|
3042
|
+
for (const leaf of d.tree.leaves()) {
|
|
3043
|
+
if (leaf.content?.kind === PLACEHOLDER_KIND) {
|
|
3044
|
+
const p = leaf.content.props || {};
|
|
3045
|
+
if (p.originalKind) {
|
|
3046
|
+
d.tree.setLeafContent(
|
|
3047
|
+
leaf.id,
|
|
3048
|
+
{ kind: p.originalKind, props: p.originalProps || {} },
|
|
3049
|
+
p.originalTitle || p.originalKind
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
this._canonicalize(d.tree, d);
|
|
3055
|
+
}
|
|
3056
|
+
this.renderer.tree = this.desktops.active().tree;
|
|
3057
|
+
this.renderer.render();
|
|
3058
|
+
this._notifyChange();
|
|
3059
|
+
}
|
|
3060
|
+
_persist() {
|
|
3061
|
+
if (this._persistTimer) clearTimeout(this._persistTimer);
|
|
3062
|
+
this._persistTimer = setTimeout(async () => {
|
|
3063
|
+
this._persistTimer = null;
|
|
3064
|
+
await saveDesktops(this.host?.state, this.desktops.serialize());
|
|
3065
|
+
}, 500);
|
|
3066
|
+
}
|
|
3067
|
+
// ── Content loading ─────────────────────────────────────────────
|
|
3068
|
+
/** Load content into the primary tile (last-active non-panel leaf).
|
|
3069
|
+
/** Walk back one step. Bound to Backspace.
|
|
3070
|
+
*
|
|
3071
|
+
* Priority order:
|
|
3072
|
+
* 1. Active tab's per-tab history stack (browser-style "back"):
|
|
3073
|
+
* any in-tile navigation (relationships click-through,
|
|
3074
|
+
* landing-row open, "Open" buttons inside tables) pushed
|
|
3075
|
+
* the previous state, and Backspace pops it. This is the
|
|
3076
|
+
* canonical user-facing back affordance.
|
|
3077
|
+
* 2. When the history is empty (the user landed on this page
|
|
3078
|
+
* via top-nav / palette / direct link), fall back to the
|
|
3079
|
+
* canonical taxonomy walk: a sub-page goes to its
|
|
3080
|
+
* top-nav, a top-nav page goes to Home.
|
|
3081
|
+
*
|
|
3082
|
+
* Adding a new kind to the injected taxonomy wires the fallback path
|
|
3083
|
+
* for Backspace + breadcrumb + top-nav highlight in one shot.
|
|
3084
|
+
* Adding a new "Open from X" click that should be a back-able
|
|
3085
|
+
* navigation just needs to use one of the in-tile routing
|
|
3086
|
+
* methods (openFromContext / openInLeaf / navigateActiveTab) —
|
|
3087
|
+
* they all record history by default. */
|
|
3088
|
+
navigateBack() {
|
|
3089
|
+
const tree = this._tree();
|
|
3090
|
+
const focusedId = tree.focusedLeafId;
|
|
3091
|
+
const primaryId = tree.primaryLeafId();
|
|
3092
|
+
const focusedLeaf = focusedId ? tree.get(focusedId) : null;
|
|
3093
|
+
const focusedKind = focusedLeaf?.content?.kind || "";
|
|
3094
|
+
const id = focusedId && focusedKind && !focusedKind.startsWith("panel:") && focusedKind !== "window-placeholder" ? focusedId : primaryId;
|
|
3095
|
+
if (!id) return;
|
|
3096
|
+
const prior = tree.popActiveTabHistory?.(id);
|
|
3097
|
+
if (prior && prior.kind) {
|
|
3098
|
+
const leafNow = tree.get(id);
|
|
3099
|
+
const curKind = leafNow?.content?.kind;
|
|
3100
|
+
const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
|
|
3101
|
+
if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
|
|
3102
|
+
tree.swapToPage?.(
|
|
3103
|
+
id,
|
|
3104
|
+
{
|
|
3105
|
+
kind: prior.kind,
|
|
3106
|
+
props: prior.props || {},
|
|
3107
|
+
title: prior.title || _tabTitle(prior.kind, prior.props)
|
|
3108
|
+
},
|
|
3109
|
+
curTopNav,
|
|
3110
|
+
prior.topNav,
|
|
3111
|
+
{ recordHistory: false }
|
|
3112
|
+
);
|
|
3113
|
+
} else {
|
|
3114
|
+
tree.replaceActiveTabContent(
|
|
3115
|
+
id,
|
|
3116
|
+
{ kind: prior.kind, props: prior.props || {} },
|
|
3117
|
+
prior.title || _tabTitle(prior.kind, prior.props),
|
|
3118
|
+
{ recordHistory: false }
|
|
3119
|
+
);
|
|
3120
|
+
}
|
|
3121
|
+
tree.focus(id);
|
|
3122
|
+
this.renderer.render();
|
|
3123
|
+
this._persist();
|
|
3124
|
+
this._notifyChange("navigate-back");
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
const leaf = tree.get(id);
|
|
3128
|
+
if (!leaf?.content) return;
|
|
3129
|
+
const { kind, props } = leaf.content;
|
|
3130
|
+
const up = this.taxonomy.parentOf(kind, props);
|
|
3131
|
+
if (up) {
|
|
3132
|
+
this.navigateActiveTab(id, up.kind, up.props);
|
|
3133
|
+
return;
|
|
3134
|
+
}
|
|
3135
|
+
const parent = this.taxonomy.parentKindFor(kind);
|
|
3136
|
+
if (parent) this.navigateActiveTab(id, parent);
|
|
3137
|
+
}
|
|
3138
|
+
/** True when `navigateBack` would do something user-visible — i.e.
|
|
3139
|
+
* the active tab has per-tab history, OR the current kind has a
|
|
3140
|
+
* taxonomy parent. Used by the breadcrumb to grey out / hide the
|
|
3141
|
+
* Back button when there's nowhere to go. */
|
|
3142
|
+
canNavigateBack() {
|
|
3143
|
+
const tree = this._tree();
|
|
3144
|
+
const focusedId = tree.focusedLeafId;
|
|
3145
|
+
const primaryId = tree.primaryLeafId();
|
|
3146
|
+
const focusedLeaf = focusedId ? tree.get(focusedId) : null;
|
|
3147
|
+
const focusedKind = focusedLeaf?.content?.kind || "";
|
|
3148
|
+
const id = focusedId && focusedKind && !focusedKind.startsWith("panel:") && focusedKind !== "window-placeholder" ? focusedId : primaryId;
|
|
3149
|
+
if (!id) return false;
|
|
3150
|
+
const history = tree.activeTabHistory?.(id) || [];
|
|
3151
|
+
if (history.length > 0) return true;
|
|
3152
|
+
const leaf = tree.get(id);
|
|
3153
|
+
const kind = leaf?.content?.kind;
|
|
3154
|
+
return !!(kind && this.taxonomy.parentKindFor(kind));
|
|
3155
|
+
}
|
|
3156
|
+
/** Persist editor sub-state (e.g. active sub-tab) into the active
|
|
3157
|
+
* tab's WM props so future history snapshots restore the user's
|
|
3158
|
+
* view rather than the default landing. Pure metadata write — no
|
|
3159
|
+
* re-render, no history push. */
|
|
3160
|
+
updateActiveTabProps(leafId, patch) {
|
|
3161
|
+
const tree = this._tree();
|
|
3162
|
+
if (!tree?.updateActiveTabProps) return;
|
|
3163
|
+
tree.updateActiveTabProps(leafId, patch);
|
|
3164
|
+
this._persist();
|
|
3165
|
+
}
|
|
3166
|
+
/** Navigate inside the current tab — preserves every other tab in
|
|
3167
|
+
* the leaf. Used by Backspace + breadcrumb segments + anything
|
|
3168
|
+
* else that should walk WITHIN the tile rather than reset it. */
|
|
3169
|
+
navigateActiveTab(leafId, kind, props = {}) {
|
|
3170
|
+
const tree = this._tree();
|
|
3171
|
+
const leaf = tree.get(leafId);
|
|
3172
|
+
if (!leaf || leaf.kind !== "leaf") {
|
|
3173
|
+
this.openInPrimary(kind, props);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
|
|
3177
|
+
tree.focus(leafId);
|
|
3178
|
+
this.renderer.render();
|
|
3179
|
+
this._persist();
|
|
3180
|
+
this._notifyChange("navigate-active-tab");
|
|
3181
|
+
}
|
|
3182
|
+
/** Route a navigation request to the right container based on
|
|
3183
|
+
* where it came from:
|
|
3184
|
+
*
|
|
3185
|
+
* - `ctx.windowId` set → replace the managed-window content (the
|
|
3186
|
+
* user is navigating inside a windowed tile; the window stays).
|
|
3187
|
+
* - `ctx.leafId` set AND that leaf is NOT `panel:left` → replace
|
|
3188
|
+
* the leaf's content in place (the user is in a split tile;
|
|
3189
|
+
* the click stays in that tile).
|
|
3190
|
+
* - Otherwise (palette, top-nav shortcuts, click in the
|
|
3191
|
+
* navigator panel, backspace, plain bus event) → primary tile.
|
|
3192
|
+
*
|
|
3193
|
+
* The left nav (`panel:left`) is the only tile whose clicks
|
|
3194
|
+
* intentionally jump out to the primary tile — every other
|
|
3195
|
+
* container is self-contained. */
|
|
3196
|
+
openFromContext(ctx, kind, props = {}) {
|
|
3197
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
3198
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
if (ctx?.leafId) {
|
|
3202
|
+
const tree = this._tree();
|
|
3203
|
+
const leaf = tree.get(ctx.leafId);
|
|
3204
|
+
const k = leaf?.content?.kind;
|
|
3205
|
+
if (leaf && k && k !== "panel:left" && !k.startsWith("panel:")) {
|
|
3206
|
+
this.openInLeaf(ctx.leafId, kind, props);
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
this.openInPrimary(kind, props);
|
|
3211
|
+
}
|
|
3212
|
+
/** Replace the leaf's **active tab** in place — preserves every
|
|
3213
|
+
* other tab. This is the routing for navigation that originated
|
|
3214
|
+
* INSIDE the tile: clicking a row on a landing page, walking a
|
|
3215
|
+
* breadcrumb segment, Backspace. Outside-the-tile navigation
|
|
3216
|
+
* (top-nav, palette, side-nav) uses `openInPrimary` instead, which
|
|
3217
|
+
* resets the leaf to a single fresh tab.
|
|
3218
|
+
*
|
|
3219
|
+
* For a leaf that has only one tab (the common case before the
|
|
3220
|
+
* tab feature shipped), the behavior is identical to the old
|
|
3221
|
+
* setLeafContent: one tab in, one tab out. */
|
|
3222
|
+
openInLeaf(leafId, kind, props = {}) {
|
|
3223
|
+
const tree = this._tree();
|
|
3224
|
+
const leaf = tree.get(leafId);
|
|
3225
|
+
if (!leaf || leaf.kind !== "leaf") {
|
|
3226
|
+
this.openInPrimary(kind, props);
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
|
|
3230
|
+
tree.focus(leafId);
|
|
3231
|
+
this.renderer.render();
|
|
3232
|
+
this._persist();
|
|
3233
|
+
this._notifyChange();
|
|
3234
|
+
}
|
|
3235
|
+
/** Replace a managed window's content in place. Tears down the
|
|
3236
|
+
* previous mount, mounts the new kind into the same contentEl,
|
|
3237
|
+
* and updates the window's title. */
|
|
3238
|
+
openInWindow(winId, kind, props = {}) {
|
|
3239
|
+
const rec = this._windowToLeaf.get(winId);
|
|
3240
|
+
if (!rec) {
|
|
3241
|
+
this.openInPrimary(kind, props);
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
try {
|
|
3245
|
+
rec.mountInfo?.destroy?.();
|
|
3246
|
+
} catch {
|
|
3247
|
+
}
|
|
3248
|
+
rec.contentEl.innerHTML = "";
|
|
3249
|
+
const mountInfo = this.content.mount(
|
|
3250
|
+
kind,
|
|
3251
|
+
rec.contentEl,
|
|
3252
|
+
props,
|
|
3253
|
+
{ ...this.ctx, wm: this, windowId: winId }
|
|
3254
|
+
);
|
|
3255
|
+
rec.mountInfo = mountInfo;
|
|
3256
|
+
rec.original = {
|
|
3257
|
+
kind,
|
|
3258
|
+
props: { ...props || {} },
|
|
3259
|
+
title: mountInfo?.title || kind
|
|
3260
|
+
};
|
|
3261
|
+
try {
|
|
3262
|
+
const titleEl = rec.window.element?.querySelector(".twm-managed-window__title");
|
|
3263
|
+
if (titleEl) titleEl.textContent = rec.original.title;
|
|
3264
|
+
if (rec.window) rec.window.title = rec.original.title;
|
|
3265
|
+
} catch {
|
|
3266
|
+
}
|
|
3267
|
+
this._persist();
|
|
3268
|
+
this._notifyChange("window-content-changed");
|
|
3269
|
+
}
|
|
3270
|
+
/** Load content into the primary tile (last-active non-panel leaf).
|
|
3271
|
+
* If only panel tiles exist (or the tree is empty), spawn a new
|
|
3272
|
+
* content leaf and canonicalize so the panels wrap it. */
|
|
3273
|
+
openInPrimary(kind, props = {}) {
|
|
3274
|
+
const tree = this._tree();
|
|
3275
|
+
let leafId = tree.primaryLeafId();
|
|
3276
|
+
let spawned = false;
|
|
3277
|
+
if (!leafId) {
|
|
3278
|
+
leafId = this._spawnContentLeaf(tree);
|
|
3279
|
+
spawned = true;
|
|
3280
|
+
}
|
|
3281
|
+
if (!leafId) return;
|
|
3282
|
+
const leaf = tree.get(leafId);
|
|
3283
|
+
const currentTopNav = leaf?.content?.kind ? this.taxonomy.topNavFor(leaf.content.kind) || null : null;
|
|
3284
|
+
const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
|
|
3285
|
+
const title = _tabTitle(kind, props);
|
|
3286
|
+
tree.swapToPage(
|
|
3287
|
+
leafId,
|
|
3288
|
+
{ kind, props, title },
|
|
3289
|
+
currentTopNav,
|
|
3290
|
+
targetTopNav
|
|
3291
|
+
);
|
|
3292
|
+
tree.focus(leafId);
|
|
3293
|
+
if (spawned) this._canonicalize(tree, this.desktops.active());
|
|
3294
|
+
this.renderer.render();
|
|
3295
|
+
this._persist();
|
|
3296
|
+
this._notifyChange();
|
|
3297
|
+
}
|
|
3298
|
+
// ── Split / close / focus / move ────────────────────────────────
|
|
3299
|
+
split(dir) {
|
|
3300
|
+
const tree = this._tree();
|
|
3301
|
+
const focused = tree.focusedLeafId;
|
|
3302
|
+
if (!focused) return;
|
|
3303
|
+
tree.split(focused, dir);
|
|
3304
|
+
this.renderer.render();
|
|
3305
|
+
this._persist();
|
|
3306
|
+
this._notifyChange();
|
|
3307
|
+
}
|
|
3308
|
+
/** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
|
|
3309
|
+
* created sibling — the "open this content in a new split" primitive
|
|
3310
|
+
* behind the code-pane split buttons, the per-pane context menu and
|
|
3311
|
+
* Alt+Shift+H / Alt+Shift+V. The source leaf keeps its content
|
|
3312
|
+
* untouched; the new pane gets a FRESH mount (so e.g. opening a Code
|
|
3313
|
+
* pane in a split never dismounts the original). Returns the new
|
|
3314
|
+
* leaf id, or null if the leaf can't be split. */
|
|
3315
|
+
splitLeafWith(leafId, dir, kind, props = {}, title = "") {
|
|
3316
|
+
const tree = this._tree();
|
|
3317
|
+
const src = leafId ? tree.get(leafId) : null;
|
|
3318
|
+
if (!src || src.kind !== "leaf") return null;
|
|
3319
|
+
const newId = tree.split(leafId, dir);
|
|
3320
|
+
if (!newId) return null;
|
|
3321
|
+
tree.setLeafContent(newId, { kind, props }, title || _tabTitle(kind, props));
|
|
3322
|
+
tree.focus(newId);
|
|
3323
|
+
this.renderer.render();
|
|
3324
|
+
this._persist();
|
|
3325
|
+
this._notifyChange("split-with");
|
|
3326
|
+
return newId;
|
|
3327
|
+
}
|
|
3328
|
+
/** Resolve the focused tile's active content for the keyboard-driven
|
|
3329
|
+
* "open focused tile elsewhere" chords (Alt+T / Alt+N / Alt+Shift+H /
|
|
3330
|
+
* Alt+Shift+V). Returns null for panels, window placeholders and
|
|
3331
|
+
* empty tiles — none of which can be meaningfully duplicated. */
|
|
3332
|
+
_focusedContent() {
|
|
3333
|
+
const tree = this._tree();
|
|
3334
|
+
const id = tree.focusedLeafId;
|
|
3335
|
+
const leaf = id ? tree.get(id) : null;
|
|
3336
|
+
if (!leaf || leaf.kind !== "leaf" || !leaf.content) return null;
|
|
3337
|
+
const kind = leaf.content.kind;
|
|
3338
|
+
if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith("panel:")) return null;
|
|
3339
|
+
return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
|
|
3340
|
+
}
|
|
3341
|
+
/** Alt+T — open the focused tile's content in a new tab on that tile. */
|
|
3342
|
+
openFocusedInTab() {
|
|
3343
|
+
const c = this._focusedContent();
|
|
3344
|
+
if (!c) return;
|
|
3345
|
+
this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
|
|
3346
|
+
}
|
|
3347
|
+
/** Alt+N — open the focused tile's content in a fresh managed window.
|
|
3348
|
+
* Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
|
|
3349
|
+
openFocusedInWindow() {
|
|
3350
|
+
const c = this._focusedContent();
|
|
3351
|
+
if (!c) return;
|
|
3352
|
+
this._navigateWindow(c.kind, c.props);
|
|
3353
|
+
}
|
|
3354
|
+
/** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
|
|
3355
|
+
* copy of its content in the new pane. Falls back to an empty split
|
|
3356
|
+
* when the focused tile has no duplicable content (e.g. a panel). */
|
|
3357
|
+
splitFocusedWith(dir) {
|
|
3358
|
+
const c = this._focusedContent();
|
|
3359
|
+
if (!c) {
|
|
3360
|
+
this.split(dir);
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
|
|
3364
|
+
}
|
|
3365
|
+
closeFocused() {
|
|
3366
|
+
const tree = this._tree();
|
|
3367
|
+
const focusedId = tree.focusedLeafId;
|
|
3368
|
+
if (!focusedId) return;
|
|
3369
|
+
const leaf = tree.get(focusedId);
|
|
3370
|
+
const kind = leaf?.content?.kind;
|
|
3371
|
+
if (kind === PLACEHOLDER_KIND) {
|
|
3372
|
+
const winId = leaf.content?.props?.windowId;
|
|
3373
|
+
const rec = winId ? this._windowToLeaf.get(winId) : null;
|
|
3374
|
+
if (rec) {
|
|
3375
|
+
this._windowToLeaf.delete(winId);
|
|
3376
|
+
try {
|
|
3377
|
+
rec.window.close({ force: true });
|
|
3378
|
+
} catch {
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
tree.close(focusedId);
|
|
3382
|
+
} else if (PANEL_KINDS.has(kind)) {
|
|
3383
|
+
const d = this.desktops.active();
|
|
3384
|
+
const side = kind.split(":")[1];
|
|
3385
|
+
d.panels[side] = false;
|
|
3386
|
+
this._canonicalize(tree, d);
|
|
3387
|
+
} else {
|
|
3388
|
+
tree.close(focusedId);
|
|
3389
|
+
}
|
|
3390
|
+
this._canonicalize(tree, this.desktops.active());
|
|
3391
|
+
this.renderer.render();
|
|
3392
|
+
this._persist();
|
|
3393
|
+
this._notifyChange("tile-closed");
|
|
3394
|
+
}
|
|
3395
|
+
/** Close the ACTIVE tab of a leaf (e.g. a Cancel button inside the
|
|
3396
|
+
* tab's content). Mirrors the tab-strip × handler: remove just that
|
|
3397
|
+
* tab, or close the whole tile if it was the last one. Lets content
|
|
3398
|
+
* self-close without nuking sibling tabs. */
|
|
3399
|
+
closeActiveTab(leafId) {
|
|
3400
|
+
const tree = this._tree();
|
|
3401
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
3402
|
+
if (!leaf || leaf.kind !== "leaf") {
|
|
3403
|
+
this.closeFocused();
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
const tabs = leaf.tabs || [];
|
|
3407
|
+
if (tabs.length <= 1) {
|
|
3408
|
+
tree.focus(leafId);
|
|
3409
|
+
this.closeFocused();
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
3413
|
+
tree.removeLeafTab(leafId, idx);
|
|
3414
|
+
tree.focus(leafId);
|
|
3415
|
+
this.renderer.render();
|
|
3416
|
+
this._persist();
|
|
3417
|
+
this._notifyChange("twm-tab-close");
|
|
3418
|
+
}
|
|
3419
|
+
/** Insert a new empty content leaf at the centre of the layout
|
|
3420
|
+
* (wraps the current root in an h-split with the content on the
|
|
3421
|
+
* left, 4:1 ratio). If the tree is empty, becomes the root. */
|
|
3422
|
+
_spawnContentLeaf(tree) {
|
|
3423
|
+
const newLeaf = makeLeaf({ content: null, title: "" });
|
|
3424
|
+
if (!tree.rootId) {
|
|
3425
|
+
tree.setRoot(newLeaf);
|
|
3426
|
+
return newLeaf.id;
|
|
3427
|
+
}
|
|
3428
|
+
tree._register(newLeaf);
|
|
3429
|
+
const split = {
|
|
3430
|
+
id: `split-spawn-${Date.now().toString(36)}`,
|
|
3431
|
+
kind: "split",
|
|
3432
|
+
parentId: null,
|
|
3433
|
+
dir: "h",
|
|
3434
|
+
children: [newLeaf.id, tree.rootId],
|
|
3435
|
+
sizes: [4, 1]
|
|
3436
|
+
};
|
|
3437
|
+
tree.nodes.set(split.id, split);
|
|
3438
|
+
const oldRoot = tree.get(tree.rootId);
|
|
3439
|
+
if (oldRoot) oldRoot.parentId = split.id;
|
|
3440
|
+
newLeaf.parentId = split.id;
|
|
3441
|
+
tree.rootId = split.id;
|
|
3442
|
+
tree.focus(newLeaf.id);
|
|
3443
|
+
return newLeaf.id;
|
|
3444
|
+
}
|
|
3445
|
+
focusDir(dir) {
|
|
3446
|
+
if (this._tree().focusDir(dir)) {
|
|
3447
|
+
this.renderer._updateFocusClasses();
|
|
3448
|
+
this._notifyChange();
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
/** The `.twm-leaf` DOM element of the currently focused leaf on the
|
|
3452
|
+
* active desktop, or null. The panel-key router consults this to
|
|
3453
|
+
* decide which panel owns the keyboard — it is the single source of
|
|
3454
|
+
* truth for "the selected tile". */
|
|
3455
|
+
focusedLeafEl() {
|
|
3456
|
+
const id = this._tree().focusedLeafId;
|
|
3457
|
+
return id ? this.renderer.leafEl(id) : null;
|
|
3458
|
+
}
|
|
3459
|
+
moveFocused(dir) {
|
|
3460
|
+
if (this._tree().moveDir(dir)) {
|
|
3461
|
+
this.renderer.render();
|
|
3462
|
+
this._persist();
|
|
3463
|
+
this._notifyChange();
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
// ── Tile <-> Managed window ─────────────────────────────────────
|
|
3467
|
+
/** Promote the focused tile into a managed window and CLOSE the
|
|
3468
|
+
* source tile — promoting means the content leaves the grid, so the
|
|
3469
|
+
* origin slot is removed rather than left as an empty placeholder.
|
|
3470
|
+
* "Back to tile" re-docks the content into the primary tile. */
|
|
3471
|
+
toggleManagedFocused() {
|
|
3472
|
+
const tree = this._tree();
|
|
3473
|
+
const focused = tree.focused();
|
|
3474
|
+
if (!focused || !focused.content) return;
|
|
3475
|
+
if (PANEL_KINDS.has(focused.content.kind)) return;
|
|
3476
|
+
if (focused.content.kind === PLACEHOLDER_KIND) return;
|
|
3477
|
+
const leafId = focused.id;
|
|
3478
|
+
const desktopIdx = this.desktops.activeIdx;
|
|
3479
|
+
const original = {
|
|
3480
|
+
kind: focused.content.kind,
|
|
3481
|
+
props: { ...focused.content.props || {} },
|
|
3482
|
+
title: focused.title
|
|
3483
|
+
};
|
|
3484
|
+
const contentEl = document.createElement("div");
|
|
3485
|
+
contentEl.className = "twm-window-content";
|
|
3486
|
+
contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
|
|
3487
|
+
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
3488
|
+
const mountInfo = this.content.mount(
|
|
3489
|
+
original.kind,
|
|
3490
|
+
contentEl,
|
|
3491
|
+
original.props,
|
|
3492
|
+
{ ...this.ctx, wm: this, windowId: winId }
|
|
3493
|
+
);
|
|
3494
|
+
const win = new ManagedWindow({
|
|
3495
|
+
id: winId,
|
|
3496
|
+
title: mountInfo?.title || original.title,
|
|
3497
|
+
icon: "web_asset",
|
|
3498
|
+
content: contentEl,
|
|
3499
|
+
canMinimize: true,
|
|
3500
|
+
canMaximize: true,
|
|
3501
|
+
canResize: true,
|
|
3502
|
+
modal: false,
|
|
3503
|
+
onClose: () => this._onManagedWindowClosed(winId, mountInfo)
|
|
3504
|
+
});
|
|
3505
|
+
this._windowToLeaf.set(winId, {
|
|
3506
|
+
// Promoting CLOSES the source tile — the content lives in the
|
|
3507
|
+
// window now, not the tree. leafId is null so "back to tile"
|
|
3508
|
+
// re-docks into the primary tile (see _onManagedWindowClosed).
|
|
3509
|
+
leafId: null,
|
|
3510
|
+
desktopIdx,
|
|
3511
|
+
original,
|
|
3512
|
+
mountInfo,
|
|
3513
|
+
window: win,
|
|
3514
|
+
contentEl,
|
|
3515
|
+
// Set to true by bringBackWindow so the close path knows to
|
|
3516
|
+
// restore the content instead of destroying it.
|
|
3517
|
+
_demoting: false
|
|
3518
|
+
});
|
|
3519
|
+
tree.close(leafId);
|
|
3520
|
+
this._canonicalize(tree, this.desktops.active());
|
|
3521
|
+
this.renderer.render();
|
|
3522
|
+
win.show();
|
|
3523
|
+
this._decorateManagedWindow(win, winId);
|
|
3524
|
+
this._persist();
|
|
3525
|
+
this._notifyChange("window-promoted");
|
|
3526
|
+
}
|
|
3527
|
+
/** Dock the window's content back into the desktop's primary tile
|
|
3528
|
+
* (the source tile was closed on promote), then close the window. */
|
|
3529
|
+
bringBackWindow(windowId) {
|
|
3530
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
3531
|
+
if (!rec) return;
|
|
3532
|
+
rec._demoting = true;
|
|
3533
|
+
try {
|
|
3534
|
+
rec.window.close({ force: true });
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
console.warn("[wm] bringBack: close failed", err);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
/** Re-home a managed window to another desktop. The window itself
|
|
3540
|
+
* stays on screen (managed windows are global) and leaves no tile
|
|
3541
|
+
* behind on either desktop; only its "home" changes, so bringing it
|
|
3542
|
+
* back will land on the new desktop's primary tile. */
|
|
3543
|
+
moveWindowToDesktop(windowId, targetIdx) {
|
|
3544
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
3545
|
+
if (!rec) return;
|
|
3546
|
+
if (rec.desktopIdx === targetIdx) return;
|
|
3547
|
+
this.desktops.ensureCount(targetIdx + 1);
|
|
3548
|
+
rec.desktopIdx = targetIdx;
|
|
3549
|
+
this._persist();
|
|
3550
|
+
this._notifyChange("window-moved");
|
|
3551
|
+
}
|
|
3552
|
+
_onManagedWindowClosed(winId, mountInfo) {
|
|
3553
|
+
const rec = this._windowToLeaf.get(winId);
|
|
3554
|
+
this._windowToLeaf.delete(winId);
|
|
3555
|
+
if (!rec) return;
|
|
3556
|
+
try {
|
|
3557
|
+
mountInfo?.destroy?.();
|
|
3558
|
+
} catch {
|
|
3559
|
+
}
|
|
3560
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
3561
|
+
if (!tree) return;
|
|
3562
|
+
if (rec._demoting) {
|
|
3563
|
+
let pid = tree.primaryLeafId();
|
|
3564
|
+
let spawned = false;
|
|
3565
|
+
if (!pid) {
|
|
3566
|
+
pid = this._spawnContentLeaf(tree);
|
|
3567
|
+
spawned = true;
|
|
3568
|
+
}
|
|
3569
|
+
if (pid) {
|
|
3570
|
+
tree.appendLeafTab(
|
|
3571
|
+
pid,
|
|
3572
|
+
{ kind: rec.original.kind, props: rec.original.props },
|
|
3573
|
+
rec.original.title
|
|
3574
|
+
);
|
|
3575
|
+
tree.focus(pid);
|
|
3576
|
+
if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
if (this.desktops.active().tree === tree) this.renderer.render();
|
|
3580
|
+
this._persist();
|
|
3581
|
+
this._notifyChange(rec._demoting ? "window-demoted" : "window-closed");
|
|
3582
|
+
}
|
|
3583
|
+
/** Post-show DOM hook: inject a "back to tile" button into the
|
|
3584
|
+
* window chrome and wire a right-click context menu on the topbar. */
|
|
3585
|
+
_decorateManagedWindow(win, winId) {
|
|
3586
|
+
const topbar = win.element?.querySelector?.(".twm-managed-window__topbar");
|
|
3587
|
+
const buttons = topbar?.querySelector?.(".twm-managed-window__buttons");
|
|
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);
|
|
3601
|
+
topbar.addEventListener("contextmenu", (e) => {
|
|
3602
|
+
e.preventDefault();
|
|
3603
|
+
const rec = this._windowToLeaf.get(winId);
|
|
3604
|
+
if (!rec) return;
|
|
3605
|
+
const items = [
|
|
3606
|
+
{ label: "Back to tile", icon: "close_fullscreen", action: "back" }
|
|
3607
|
+
];
|
|
3608
|
+
if (this.desktops.desktops.length > 1) {
|
|
3609
|
+
items.push({ separator: true });
|
|
3610
|
+
for (const [i, d] of this.desktops.desktops.entries()) {
|
|
3611
|
+
if (i === rec.desktopIdx) continue;
|
|
3612
|
+
items.push({
|
|
3613
|
+
label: `Move to desktop ${d.label}`,
|
|
3614
|
+
icon: "sweep",
|
|
3615
|
+
action: `move:${i}`
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
items.push({ separator: true });
|
|
3620
|
+
items.push({
|
|
3621
|
+
label: "Close window",
|
|
3622
|
+
icon: "close",
|
|
3623
|
+
action: "close",
|
|
3624
|
+
danger: true
|
|
3625
|
+
});
|
|
3626
|
+
showContextMenu(e.clientX, e.clientY, items, (action) => {
|
|
3627
|
+
if (action === "back") this.bringBackWindow(winId);
|
|
3628
|
+
else if (action === "close") {
|
|
3629
|
+
try {
|
|
3630
|
+
win.close({ force: true });
|
|
3631
|
+
} catch {
|
|
3632
|
+
}
|
|
3633
|
+
} else if (action?.startsWith?.("move:")) {
|
|
3634
|
+
this.moveWindowToDesktop(winId, Number(action.slice(5)));
|
|
3635
|
+
}
|
|
3636
|
+
});
|
|
3637
|
+
});
|
|
3638
|
+
}
|
|
3639
|
+
// ── Panel-tiles (left nav / right / bottom) ─────────────────────
|
|
3640
|
+
/** Panels are virtual: they live in the active desktop's tree as
|
|
3641
|
+
* leaves with content kinds 'panel:left', 'panel:right',
|
|
3642
|
+
* 'panel:bottom'. Toggling either inserts them at the appropriate
|
|
3643
|
+
* edge (a recursive split) or closes that leaf. */
|
|
3644
|
+
isPanelOpen(side) {
|
|
3645
|
+
const d = this.desktops.active();
|
|
3646
|
+
return !!d.panels[side];
|
|
3647
|
+
}
|
|
3648
|
+
togglePanel(side) {
|
|
3649
|
+
const d = this.desktops.active();
|
|
3650
|
+
d.panels[side] = !d.panels[side];
|
|
3651
|
+
this._canonicalize(this._tree(), d);
|
|
3652
|
+
this.renderer.render();
|
|
3653
|
+
this._persist();
|
|
3654
|
+
this._notifyChange();
|
|
3655
|
+
}
|
|
3656
|
+
/** Rebuild the tree so panel:* leaves sit in the canonical positions:
|
|
3657
|
+
*
|
|
3658
|
+
* v-split
|
|
3659
|
+
* ├ h-split ← the "content row"
|
|
3660
|
+
* │ ├ panel:left (if open)
|
|
3661
|
+
* │ ├ content (whatever non-panel subtree)
|
|
3662
|
+
* │ └ panel:right (if open)
|
|
3663
|
+
* └ panel:bottom (if open, full width below the content row)
|
|
3664
|
+
*
|
|
3665
|
+
* Existing panel leaves are detached + re-attached (same id), so the
|
|
3666
|
+
* renderer cache keeps the mounted content (nav filter state, etc.). */
|
|
3667
|
+
_canonicalize(tree, desktop) {
|
|
3668
|
+
const panel = {};
|
|
3669
|
+
for (const leaf of tree.leaves()) {
|
|
3670
|
+
const kind = leaf.content?.kind;
|
|
3671
|
+
if (kind === "panel:left") panel.left = leaf;
|
|
3672
|
+
else if (kind === "panel:right") panel.right = leaf;
|
|
3673
|
+
else if (kind === "panel:bottom") panel.bottom = leaf;
|
|
3674
|
+
}
|
|
3675
|
+
const detach = (leaf) => {
|
|
3676
|
+
if (!leaf) return;
|
|
3677
|
+
const pid = leaf.parentId;
|
|
3678
|
+
if (!pid) {
|
|
3679
|
+
tree.rootId = null;
|
|
3680
|
+
leaf.parentId = null;
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
const parent = tree.get(pid);
|
|
3684
|
+
if (!parent) return;
|
|
3685
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
3686
|
+
if (idx >= 0) {
|
|
3687
|
+
parent.children.splice(idx, 1);
|
|
3688
|
+
parent.sizes.splice(idx, 1);
|
|
3689
|
+
}
|
|
3690
|
+
leaf.parentId = null;
|
|
3691
|
+
tree._collapse(parent);
|
|
3692
|
+
};
|
|
3693
|
+
detach(panel.left);
|
|
3694
|
+
detach(panel.right);
|
|
3695
|
+
detach(panel.bottom);
|
|
3696
|
+
let seededSide = null;
|
|
3697
|
+
if (!tree.rootId) {
|
|
3698
|
+
seededSide = desktop.panels.left ? "left" : desktop.panels.bottom ? "bottom" : desktop.panels.right ? "right" : null;
|
|
3699
|
+
if (seededSide) {
|
|
3700
|
+
const seed = panel[seededSide] || makeLeaf({
|
|
3701
|
+
content: { kind: `panel:${seededSide}`, props: {} },
|
|
3702
|
+
title: PANEL_TITLES[seededSide] || seededSide
|
|
3703
|
+
});
|
|
3704
|
+
if (panel[seededSide] && PANEL_TITLES[seededSide]) {
|
|
3705
|
+
panel[seededSide].title = PANEL_TITLES[seededSide];
|
|
3706
|
+
}
|
|
3707
|
+
if (!panel[seededSide]) tree._register(seed);
|
|
3708
|
+
if (!tree.nodes.has(seed.id)) tree.nodes.set(seed.id, seed);
|
|
3709
|
+
seed.parentId = null;
|
|
3710
|
+
tree.rootId = seed.id;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
const wrap = (panelLeaf, side) => {
|
|
3714
|
+
if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
|
|
3715
|
+
tree.nodes.set(panelLeaf.id, panelLeaf);
|
|
3716
|
+
}
|
|
3717
|
+
const leaf = panelLeaf || makeLeaf({
|
|
3718
|
+
content: { kind: `panel:${side}`, props: {} },
|
|
3719
|
+
title: PANEL_TITLES[side] || side
|
|
3720
|
+
});
|
|
3721
|
+
if (panelLeaf && PANEL_TITLES[side] && panelLeaf.title !== PANEL_TITLES[side]) {
|
|
3722
|
+
panelLeaf.title = PANEL_TITLES[side];
|
|
3723
|
+
}
|
|
3724
|
+
if (!panelLeaf) tree._register(leaf);
|
|
3725
|
+
const before = side === "left";
|
|
3726
|
+
const dir = side === "bottom" ? "v" : "h";
|
|
3727
|
+
const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
|
|
3728
|
+
const sizes = side === "bottom" ? [4, 1] : side === "left" ? [1, 4] : [4, 1];
|
|
3729
|
+
const split = {
|
|
3730
|
+
id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
|
|
3731
|
+
kind: "split",
|
|
3732
|
+
parentId: null,
|
|
3733
|
+
dir,
|
|
3734
|
+
children,
|
|
3735
|
+
sizes
|
|
3736
|
+
};
|
|
3737
|
+
tree.nodes.set(split.id, split);
|
|
3738
|
+
const oldRoot = tree.get(tree.rootId);
|
|
3739
|
+
if (oldRoot) oldRoot.parentId = split.id;
|
|
3740
|
+
leaf.parentId = split.id;
|
|
3741
|
+
tree.rootId = split.id;
|
|
3742
|
+
};
|
|
3743
|
+
if (desktop.panels.left && seededSide !== "left" && tree.rootId) wrap(panel.left, "left");
|
|
3744
|
+
if (desktop.panels.right && seededSide !== "right" && tree.rootId) wrap(panel.right, "right");
|
|
3745
|
+
if (desktop.panels.bottom && seededSide !== "bottom" && tree.rootId) wrap(panel.bottom, "bottom");
|
|
3746
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
3747
|
+
const walk = (id) => {
|
|
3748
|
+
if (!id || reachable.has(id)) return;
|
|
3749
|
+
reachable.add(id);
|
|
3750
|
+
const n = tree.get(id);
|
|
3751
|
+
if (n?.kind === "split") n.children.forEach(walk);
|
|
3752
|
+
};
|
|
3753
|
+
walk(tree.rootId);
|
|
3754
|
+
for (const id of [...tree.nodes.keys()]) {
|
|
3755
|
+
if (!reachable.has(id)) tree.nodes.delete(id);
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
_findPanelLeaf(side) {
|
|
3759
|
+
const kind = `panel:${side}`;
|
|
3760
|
+
for (const leaf of this._tree().leaves()) {
|
|
3761
|
+
if (leaf.content?.kind === kind) return leaf;
|
|
3762
|
+
}
|
|
3763
|
+
return null;
|
|
3764
|
+
}
|
|
3765
|
+
// ── Desktops ────────────────────────────────────────────────────
|
|
3766
|
+
switchDesktop(idx) {
|
|
3767
|
+
this.desktops.ensureCount(idx + 1);
|
|
3768
|
+
if (!this.desktops.switchTo(idx)) return;
|
|
3769
|
+
const d = this.desktops.active();
|
|
3770
|
+
this._canonicalize(d.tree, d);
|
|
3771
|
+
this.renderer.tree = d.tree;
|
|
3772
|
+
this.renderer.render();
|
|
3773
|
+
this._persist();
|
|
3774
|
+
this._notifyChange();
|
|
3775
|
+
}
|
|
3776
|
+
addDesktop() {
|
|
3777
|
+
this.desktops.addDesktop();
|
|
3778
|
+
this._notifyChange();
|
|
3779
|
+
this._persist();
|
|
3780
|
+
}
|
|
3781
|
+
/** Step to the previous (-1) or next (+1) existing desktop, wrapping
|
|
3782
|
+
* around. No-op with a single desktop. Iterates only desktops that
|
|
3783
|
+
* already exist — use switchDesktop(idx) to create-on-demand. */
|
|
3784
|
+
cycleDesktop(dir) {
|
|
3785
|
+
const m = this.desktops;
|
|
3786
|
+
const n = m.desktops.length;
|
|
3787
|
+
if (n <= 1) return;
|
|
3788
|
+
const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
|
|
3789
|
+
this.switchDesktop(next);
|
|
3790
|
+
}
|
|
3791
|
+
/** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
|
|
3792
|
+
* Wraps; no-op when the focused leaf has 0 or 1 tabs. */
|
|
3793
|
+
cycleFocusedTab(dir) {
|
|
3794
|
+
const tree = this._tree();
|
|
3795
|
+
const leafId = tree.focusedLeafId;
|
|
3796
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
3797
|
+
const tabs = leaf && Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
3798
|
+
if (tabs.length <= 1) return;
|
|
3799
|
+
const cur = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
3800
|
+
const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
|
|
3801
|
+
this._leafTabAction(leafId, "switch", { idx: next });
|
|
3802
|
+
}
|
|
3803
|
+
removeDesktop(idx) {
|
|
3804
|
+
const m = this.desktops;
|
|
3805
|
+
if (m.desktops.length <= 1) return false;
|
|
3806
|
+
if (idx < 0 || idx >= m.desktops.length) return false;
|
|
3807
|
+
m.desktops.splice(idx, 1);
|
|
3808
|
+
if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
|
|
3809
|
+
this.renderer.tree = m.active().tree;
|
|
3810
|
+
this.renderer.render();
|
|
3811
|
+
this._persist();
|
|
3812
|
+
this._notifyChange();
|
|
3813
|
+
return true;
|
|
3814
|
+
}
|
|
3815
|
+
moveFocusedToDesktop(idx) {
|
|
3816
|
+
const tree = this._tree();
|
|
3817
|
+
const focused = tree.focused();
|
|
3818
|
+
if (!focused || !focused.content) return;
|
|
3819
|
+
const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
|
|
3820
|
+
this.desktops.ensureCount(idx + 1);
|
|
3821
|
+
const target = this.desktops.desktops[idx];
|
|
3822
|
+
const targetTree = target.tree;
|
|
3823
|
+
const primary = targetTree.primaryLeafId();
|
|
3824
|
+
if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
|
|
3825
|
+
tree.close(focused.id);
|
|
3826
|
+
if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
|
|
3827
|
+
this.renderer.render();
|
|
3828
|
+
this._persist();
|
|
3829
|
+
this._notifyChange();
|
|
3830
|
+
}
|
|
3831
|
+
// ── Internals ───────────────────────────────────────────────────
|
|
3832
|
+
_tree() {
|
|
3833
|
+
return this.desktops.active().tree;
|
|
3834
|
+
}
|
|
3835
|
+
_leafAction(leafId, action) {
|
|
3836
|
+
const tree = this._tree();
|
|
3837
|
+
tree.focus(leafId);
|
|
3838
|
+
if (action === "close") this.closeFocused();
|
|
3839
|
+
else if (action === "promote") this.toggleManagedFocused();
|
|
3840
|
+
else if (action === "split-h") this.split("h");
|
|
3841
|
+
else if (action === "split-v") this.split("v");
|
|
3842
|
+
}
|
|
3843
|
+
/** Tab-strip event dispatcher. The renderer fires actions
|
|
3844
|
+
* (`switch` / `close` / `move` / `open-menu`) and the WM
|
|
3845
|
+
* translates them into tile-tree mutations + a re-render. */
|
|
3846
|
+
_leafTabAction(leafId, action, data = {}) {
|
|
3847
|
+
const tree = this._tree();
|
|
3848
|
+
const leaf = tree.get(leafId);
|
|
3849
|
+
if (!leaf || leaf.kind !== "leaf") return;
|
|
3850
|
+
if (action === "switch") {
|
|
3851
|
+
tree.setActiveLeafTab(leafId, data.idx);
|
|
3852
|
+
tree.focus(leafId);
|
|
3853
|
+
this.renderer.render();
|
|
3854
|
+
this._persist();
|
|
3855
|
+
this._notifyChange("tab-switch");
|
|
3856
|
+
return;
|
|
3857
|
+
}
|
|
3858
|
+
if (action === "close") {
|
|
3859
|
+
const tabs = leaf.tabs || [];
|
|
3860
|
+
if (tabs.length <= 1) {
|
|
3861
|
+
tree.focus(leafId);
|
|
3862
|
+
this.closeFocused();
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
tree.removeLeafTab(leafId, data.idx);
|
|
3866
|
+
tree.focus(leafId);
|
|
3867
|
+
this.renderer.render();
|
|
3868
|
+
this._persist();
|
|
3869
|
+
this._notifyChange("twm-tab-close");
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
if (action === "move") {
|
|
3873
|
+
tree.moveLeafTab(leafId, data.from, data.to);
|
|
3874
|
+
this.renderer.render();
|
|
3875
|
+
this._persist();
|
|
3876
|
+
this._notifyChange("tab-move");
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
if (action === "menu") {
|
|
3880
|
+
this._showTabContextMenu(leafId, data.idx, data.x, data.y);
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
if (action === "close-others") {
|
|
3884
|
+
tree.closeOtherTabs(leafId, data.idx);
|
|
3885
|
+
this.renderer.render();
|
|
3886
|
+
this._persist();
|
|
3887
|
+
this._notifyChange("tab-close-others");
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
if (action === "close-right") {
|
|
3891
|
+
tree.closeTabsAfter(leafId, data.idx);
|
|
3892
|
+
this.renderer.render();
|
|
3893
|
+
this._persist();
|
|
3894
|
+
this._notifyChange("tab-close-right");
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
if (action === "close-left") {
|
|
3898
|
+
tree.closeTabsBefore(leafId, data.idx);
|
|
3899
|
+
this.renderer.render();
|
|
3900
|
+
this._persist();
|
|
3901
|
+
this._notifyChange("tab-close-left");
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (action === "open-menu") {
|
|
3905
|
+
try {
|
|
3906
|
+
this.ctx?.onTileTabMenu?.(leafId, data.x, data.y);
|
|
3907
|
+
} catch (err) {
|
|
3908
|
+
console.warn("[wm] tab menu hook failed", err);
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
/** Central navigation entry point. Every nav action — left menu,
|
|
3913
|
+
* breadcrumb, landing-row click, in-tile reference, panel
|
|
3914
|
+
* reference, entity-to-entity — should route through this so the
|
|
3915
|
+
* one holistic routing concept lives in one place.
|
|
3916
|
+
*
|
|
3917
|
+
* A link declares its target as TWO axes:
|
|
3918
|
+
*
|
|
3919
|
+
* `opts.dest` — WHERE the content lands:
|
|
3920
|
+
* 'main' → the primary content tile (default).
|
|
3921
|
+
* The nav panel always uses this.
|
|
3922
|
+
* 'origin' → the tile/window the click came from
|
|
3923
|
+
* (needs `ctx`). Breadcrumbs use this so
|
|
3924
|
+
* a segment click stays in the current
|
|
3925
|
+
* tile (and inside a floating window when
|
|
3926
|
+
* windowed).
|
|
3927
|
+
* 'window' → a fresh managed window.
|
|
3928
|
+
* `opts.newTab` — HOW it lands in that destination:
|
|
3929
|
+
* false → replace the destination's active tab
|
|
3930
|
+
* (default). true → append a NEW tab.
|
|
3931
|
+
* Ignored for 'window' (one window = one
|
|
3932
|
+
* content).
|
|
3933
|
+
*
|
|
3934
|
+
* `opts.ctx` — the caller's mount context (`leafId`,
|
|
3935
|
+
* `windowId`); required for `dest:'origin'`.
|
|
3936
|
+
* `opts.transient` — the appended tab is not persisted/restored
|
|
3937
|
+
* (e.g. an add-row form). Only meaningful with
|
|
3938
|
+
* `newTab:true`.
|
|
3939
|
+
*
|
|
3940
|
+
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
3941
|
+
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
3942
|
+
* 'primary'→main, 'window'→window. Prefer the two-axis form.
|
|
3943
|
+
*
|
|
3944
|
+
* The lower-level primitives (`openFromContext`,
|
|
3945
|
+
* `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
|
|
3946
|
+
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
3947
|
+
navigate(kind, props = {}, opts = {}) {
|
|
3948
|
+
const { ctx = null, transient = false } = opts;
|
|
3949
|
+
let { dest = "main", newTab = false } = opts;
|
|
3950
|
+
if (opts.target != null) {
|
|
3951
|
+
switch (opts.target) {
|
|
3952
|
+
case "window":
|
|
3953
|
+
dest = "window";
|
|
3954
|
+
newTab = false;
|
|
3955
|
+
break;
|
|
3956
|
+
case "primary":
|
|
3957
|
+
dest = "main";
|
|
3958
|
+
newTab = false;
|
|
3959
|
+
break;
|
|
3960
|
+
case "tab":
|
|
3961
|
+
dest = "origin";
|
|
3962
|
+
newTab = true;
|
|
3963
|
+
break;
|
|
3964
|
+
case "split-h":
|
|
3965
|
+
dest = "split-h";
|
|
3966
|
+
break;
|
|
3967
|
+
case "split-v":
|
|
3968
|
+
dest = "split-v";
|
|
3969
|
+
break;
|
|
3970
|
+
case "auto":
|
|
3971
|
+
default:
|
|
3972
|
+
dest = "origin";
|
|
3973
|
+
newTab = false;
|
|
3974
|
+
break;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
if (dest === "split-h" || dest === "split-v") {
|
|
3978
|
+
const dir = dest === "split-h" ? "h" : "v";
|
|
3979
|
+
const leafId = ctx?.leafId || this._tree().focusedLeafId;
|
|
3980
|
+
return this.splitLeafWith(leafId, dir, kind, props, _tabTitle(kind, props));
|
|
3981
|
+
}
|
|
3982
|
+
if (dest === "window") return this._navigateWindow(kind, props);
|
|
3983
|
+
if (dest === "main") {
|
|
3984
|
+
return newTab ? this.openInTabInPrimary(kind, props, transient) : this.openInPrimary(kind, props);
|
|
3985
|
+
}
|
|
3986
|
+
return newTab ? this._navigateTab(ctx, kind, props, transient) : this._navigateAuto(ctx, kind, props);
|
|
3987
|
+
}
|
|
3988
|
+
_navigateAuto(ctx, kind, props) {
|
|
3989
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
3990
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
if (ctx?.leafId) {
|
|
3994
|
+
const tree = this._tree();
|
|
3995
|
+
const leaf = tree.get(ctx.leafId);
|
|
3996
|
+
const k = leaf?.content?.kind;
|
|
3997
|
+
if (leaf && k && !k.startsWith("panel:")) {
|
|
3998
|
+
this.openInLeaf(ctx.leafId, kind, props);
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
this.openInPrimary(kind, props);
|
|
4003
|
+
}
|
|
4004
|
+
_navigateTab(ctx, kind, props, transient = false) {
|
|
4005
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
4006
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
4010
|
+
}
|
|
4011
|
+
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
4012
|
+
* source leaf — closing the window just disposes the content. */
|
|
4013
|
+
_navigateWindow(kind, props) {
|
|
4014
|
+
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
4015
|
+
const contentEl = document.createElement("div");
|
|
4016
|
+
contentEl.className = "twm-window-content";
|
|
4017
|
+
contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
|
|
4018
|
+
const title = _tabTitle(kind, props);
|
|
4019
|
+
const mountInfo = this.content.mount(
|
|
4020
|
+
kind,
|
|
4021
|
+
contentEl,
|
|
4022
|
+
props,
|
|
4023
|
+
{ ...this.ctx, wm: this, windowId: winId }
|
|
4024
|
+
);
|
|
4025
|
+
const win = new ManagedWindow({
|
|
4026
|
+
id: winId,
|
|
4027
|
+
title: mountInfo?.title || title,
|
|
4028
|
+
icon: "web_asset",
|
|
4029
|
+
content: contentEl,
|
|
4030
|
+
canMinimize: true,
|
|
4031
|
+
canMaximize: true,
|
|
4032
|
+
canResize: true,
|
|
4033
|
+
modal: false,
|
|
4034
|
+
onClose: () => this._onManagedWindowClosed(winId, mountInfo)
|
|
4035
|
+
});
|
|
4036
|
+
this._windowToLeaf.set(winId, {
|
|
4037
|
+
leafId: null,
|
|
4038
|
+
desktopIdx: this.desktops.activeIdx,
|
|
4039
|
+
original: {
|
|
4040
|
+
kind,
|
|
4041
|
+
props: { ...props || {} },
|
|
4042
|
+
title: mountInfo?.title || title
|
|
4043
|
+
},
|
|
4044
|
+
mountInfo,
|
|
4045
|
+
window: win,
|
|
4046
|
+
contentEl,
|
|
4047
|
+
_demoting: false
|
|
4048
|
+
});
|
|
4049
|
+
win.show();
|
|
4050
|
+
const _n = this._windowToLeaf.size;
|
|
4051
|
+
if (_n > 1 && typeof win._applyPosition === "function") {
|
|
4052
|
+
const step = (_n - 1) % 6 * 28;
|
|
4053
|
+
win.x = (win.x || 0) + step;
|
|
4054
|
+
win.y = (win.y || 0) + step;
|
|
4055
|
+
win._applyPosition();
|
|
4056
|
+
}
|
|
4057
|
+
this._decorateManagedWindow(win, winId);
|
|
4058
|
+
this._notifyChange("window-spawned");
|
|
4059
|
+
}
|
|
4060
|
+
/** Browser-style tab context menu. Items reflect the leaf's current
|
|
4061
|
+
* tab list — "Close others" is hidden when only one tab is open,
|
|
4062
|
+
* "Close to the right / left" are hidden at the edges. The actual
|
|
4063
|
+
* mutations route back through `_leafTabAction` so persistence +
|
|
4064
|
+
* notify stay in one place. */
|
|
4065
|
+
_showTabContextMenu(leafId, idx, x, y) {
|
|
4066
|
+
const tree = this._tree();
|
|
4067
|
+
const leaf = tree.get(leafId);
|
|
4068
|
+
if (!leaf || leaf.kind !== "leaf") return;
|
|
4069
|
+
const tabs = leaf.tabs || [];
|
|
4070
|
+
if (tabs.length === 0) return;
|
|
4071
|
+
const items = [
|
|
4072
|
+
{ label: "Close tab", icon: "close", action: "close" }
|
|
4073
|
+
];
|
|
4074
|
+
if (tabs.length > 1) {
|
|
4075
|
+
items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
|
|
4076
|
+
}
|
|
4077
|
+
if (idx < tabs.length - 1) {
|
|
4078
|
+
items.push({
|
|
4079
|
+
label: "Close tabs to the right",
|
|
4080
|
+
icon: "chevron_right",
|
|
4081
|
+
action: "close-right"
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
if (idx > 0) {
|
|
4085
|
+
items.push({
|
|
4086
|
+
label: "Close tabs to the left",
|
|
4087
|
+
icon: "chevron_left",
|
|
4088
|
+
action: "close-left"
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
showContextMenu(x, y, items, (action) => {
|
|
4092
|
+
this._leafTabAction(leafId, action, { idx });
|
|
4093
|
+
});
|
|
4094
|
+
}
|
|
4095
|
+
/** Open content in a new tab on the leaf where the call originated.
|
|
4096
|
+
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
4097
|
+
* routing) but uses `appendLeafTab` so the existing content
|
|
4098
|
+
* stays in place as a tab. */
|
|
4099
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
4100
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
4101
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
const tree = this._tree();
|
|
4105
|
+
let leafId = ctx?.leafId;
|
|
4106
|
+
if (!leafId) leafId = tree.primaryLeafId();
|
|
4107
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
4108
|
+
const k = leaf?.content?.kind;
|
|
4109
|
+
if (!leaf || !leafId || k && k.startsWith("panel:")) {
|
|
4110
|
+
this.openInPrimary(kind, props);
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
|
|
4114
|
+
tree.focus(leafId);
|
|
4115
|
+
this.renderer.render();
|
|
4116
|
+
this._persist();
|
|
4117
|
+
this._notifyChange("tab-open");
|
|
4118
|
+
}
|
|
4119
|
+
/** Open content as a NEW tab in the primary content tile, regardless
|
|
4120
|
+
* of where the call came from (the `dest:'main', newTab:true` path).
|
|
4121
|
+
* Unlike `openInTabFromContext`, this never falls back to a replace:
|
|
4122
|
+
* it targets the primary leaf directly and always appends, so a
|
|
4123
|
+
* click from outside the tile system (e.g. the bottom-panel
|
|
4124
|
+
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
4125
|
+
* tab in the main tile rather than swapping its content. */
|
|
4126
|
+
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
4127
|
+
const tree = this._tree();
|
|
4128
|
+
const leafId = tree.primaryLeafId();
|
|
4129
|
+
if (!leafId) {
|
|
4130
|
+
this._navigateWindow(kind, props);
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
|
|
4134
|
+
tree.focus(leafId);
|
|
4135
|
+
this.renderer.render();
|
|
4136
|
+
this._persist();
|
|
4137
|
+
this._notifyChange("tab-open");
|
|
4138
|
+
}
|
|
4139
|
+
};
|
|
4140
|
+
function _tabTitle(kind, props) {
|
|
4141
|
+
if (props && typeof props.label === "string" && props.label) return props.label;
|
|
4142
|
+
if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
|
|
4143
|
+
return kind || "";
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// src/tiling/tile_tab_menu.js
|
|
4147
|
+
var PAGE_SIZE = 10;
|
|
4148
|
+
function openTileTabMenu({
|
|
4149
|
+
x,
|
|
4150
|
+
y,
|
|
4151
|
+
leafKind,
|
|
4152
|
+
onPick,
|
|
4153
|
+
api,
|
|
4154
|
+
taxonomy,
|
|
4155
|
+
entities
|
|
4156
|
+
}) {
|
|
4157
|
+
if (!taxonomy || !entities) {
|
|
4158
|
+
console.error("[tile-tab-menu] taxonomy + entity catalog are required");
|
|
4159
|
+
return () => {
|
|
4160
|
+
};
|
|
4161
|
+
}
|
|
4162
|
+
const topNav = taxonomy.topNavFor(leafKind) || leafKind;
|
|
4163
|
+
const sources = taxonomy.sourcesFor(topNav).map((nav) => entities.get(nav)).filter(Boolean);
|
|
4164
|
+
const overlay = document.createElement("div");
|
|
4165
|
+
overlay.className = "twm-tile-tabmenu-overlay";
|
|
4166
|
+
overlay.innerHTML = `
|
|
4167
|
+
<div class="twm-tile-tabmenu" role="dialog" aria-label="Open in new tab">
|
|
4168
|
+
<header class="twm-tile-tabmenu__head">
|
|
4169
|
+
<span class="material-symbols-outlined twm-tile-tabmenu__head-icon">tab</span>
|
|
4170
|
+
<span class="twm-tile-tabmenu__head-label">Open in new tab</span>
|
|
4171
|
+
<button type="button" class="twm-tile-tabmenu__close"
|
|
4172
|
+
data-action="close"
|
|
4173
|
+
aria-label="Close">\xD7</button>
|
|
4174
|
+
</header>
|
|
4175
|
+
<div class="twm-tile-tabmenu__search">
|
|
4176
|
+
<span class="material-symbols-outlined">search</span>
|
|
4177
|
+
<input type="search"
|
|
4178
|
+
data-role="search"
|
|
4179
|
+
placeholder="Filter rows across all sections\u2026"
|
|
4180
|
+
autocomplete="off" />
|
|
4181
|
+
</div>
|
|
4182
|
+
<div class="twm-tile-tabmenu__body" data-role="body">
|
|
4183
|
+
<div class="twm-tile-tabmenu__loading">Loading\u2026</div>
|
|
4184
|
+
</div>
|
|
4185
|
+
</div>
|
|
4186
|
+
`;
|
|
4187
|
+
document.body.appendChild(overlay);
|
|
4188
|
+
const panel = overlay.querySelector(".twm-tile-tabmenu");
|
|
4189
|
+
panel.style.position = "fixed";
|
|
4190
|
+
panel.style.left = `${Math.max(8, Math.min(window.innerWidth - 360, x))}px`;
|
|
4191
|
+
panel.style.top = `${Math.max(8, Math.min(window.innerHeight - 480, y - 480 + 22))}px`;
|
|
4192
|
+
let alive = true;
|
|
4193
|
+
let _query = "";
|
|
4194
|
+
let _cursor = 0;
|
|
4195
|
+
let _visibleItems = [];
|
|
4196
|
+
const sectionState = new Map(sources.map((s) => [s.navKind, { rows: [], page: 0, shaped: [] }]));
|
|
4197
|
+
const close = () => {
|
|
4198
|
+
if (!alive) return;
|
|
4199
|
+
alive = false;
|
|
4200
|
+
document.removeEventListener("mousedown", onOutside, true);
|
|
4201
|
+
document.removeEventListener("keydown", onKey, true);
|
|
4202
|
+
overlay.remove();
|
|
4203
|
+
};
|
|
4204
|
+
const onOutside = (ev) => {
|
|
4205
|
+
if (!overlay.contains(ev.target)) close();
|
|
4206
|
+
};
|
|
4207
|
+
const moveCursor = (delta) => {
|
|
4208
|
+
if (_visibleItems.length === 0) return;
|
|
4209
|
+
_cursor = Math.max(0, Math.min(_visibleItems.length - 1, _cursor + delta));
|
|
4210
|
+
_paintCursor();
|
|
4211
|
+
};
|
|
4212
|
+
const jumpCursor = (toEnd) => {
|
|
4213
|
+
if (_visibleItems.length === 0) return;
|
|
4214
|
+
_cursor = toEnd ? _visibleItems.length - 1 : 0;
|
|
4215
|
+
_paintCursor();
|
|
4216
|
+
};
|
|
4217
|
+
const pickCursor = () => {
|
|
4218
|
+
const cur = _visibleItems[_cursor];
|
|
4219
|
+
if (!cur) return;
|
|
4220
|
+
close();
|
|
4221
|
+
try {
|
|
4222
|
+
onPick?.(cur.navKind, cur.shaped);
|
|
4223
|
+
} catch (err) {
|
|
4224
|
+
console.warn("[tile-tab-menu] pick failed", err);
|
|
4225
|
+
}
|
|
4226
|
+
};
|
|
4227
|
+
const onKey = (ev) => {
|
|
4228
|
+
if (!alive) return;
|
|
4229
|
+
if (ev.isComposing) return;
|
|
4230
|
+
switch (ev.key) {
|
|
4231
|
+
case "Escape":
|
|
4232
|
+
ev.preventDefault();
|
|
4233
|
+
close();
|
|
4234
|
+
return;
|
|
4235
|
+
case "ArrowDown":
|
|
4236
|
+
ev.preventDefault();
|
|
4237
|
+
moveCursor(1);
|
|
4238
|
+
return;
|
|
4239
|
+
case "ArrowUp":
|
|
4240
|
+
ev.preventDefault();
|
|
4241
|
+
moveCursor(-1);
|
|
4242
|
+
return;
|
|
4243
|
+
case "Home":
|
|
4244
|
+
ev.preventDefault();
|
|
4245
|
+
jumpCursor(false);
|
|
4246
|
+
return;
|
|
4247
|
+
case "End":
|
|
4248
|
+
ev.preventDefault();
|
|
4249
|
+
jumpCursor(true);
|
|
4250
|
+
return;
|
|
4251
|
+
case "Enter":
|
|
4252
|
+
ev.preventDefault();
|
|
4253
|
+
pickCursor();
|
|
4254
|
+
return;
|
|
4255
|
+
case "PageDown": {
|
|
4256
|
+
if (_query) return;
|
|
4257
|
+
ev.preventDefault();
|
|
4258
|
+
const cur = _visibleItems[_cursor];
|
|
4259
|
+
const st = cur ? sectionState.get(cur.navKind) : null;
|
|
4260
|
+
if (st) {
|
|
4261
|
+
const pages = _pageCountFor(cur.navKind);
|
|
4262
|
+
if (st.page < pages - 1) {
|
|
4263
|
+
st.page += 1;
|
|
4264
|
+
_render();
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
return;
|
|
4268
|
+
}
|
|
4269
|
+
case "PageUp": {
|
|
4270
|
+
if (_query) return;
|
|
4271
|
+
ev.preventDefault();
|
|
4272
|
+
const cur = _visibleItems[_cursor];
|
|
4273
|
+
const st = cur ? sectionState.get(cur.navKind) : null;
|
|
4274
|
+
if (st && st.page > 0) {
|
|
4275
|
+
st.page -= 1;
|
|
4276
|
+
_render();
|
|
4277
|
+
}
|
|
4278
|
+
return;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
};
|
|
4282
|
+
document.addEventListener("mousedown", onOutside, true);
|
|
4283
|
+
document.addEventListener("keydown", onKey, true);
|
|
4284
|
+
overlay.querySelector('[data-action="close"]').addEventListener("click", close);
|
|
4285
|
+
const searchInput = overlay.querySelector('[data-role="search"]');
|
|
4286
|
+
searchInput.addEventListener("input", () => {
|
|
4287
|
+
_query = searchInput.value.trim().toLowerCase();
|
|
4288
|
+
for (const st of sectionState.values()) st.page = 0;
|
|
4289
|
+
_cursor = 0;
|
|
4290
|
+
_render();
|
|
4291
|
+
});
|
|
4292
|
+
const bodyEl = overlay.querySelector('[data-role="body"]');
|
|
4293
|
+
const _pageCountFor = (navKind) => {
|
|
4294
|
+
const st = sectionState.get(navKind);
|
|
4295
|
+
if (!st) return 1;
|
|
4296
|
+
const total = (_query ? st.shaped.filter((s) => _matchesShaped(s, _query)) : st.shaped).length;
|
|
4297
|
+
return Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
4298
|
+
};
|
|
4299
|
+
const _render = () => {
|
|
4300
|
+
if (!alive) return;
|
|
4301
|
+
_visibleItems = [];
|
|
4302
|
+
const sections = [];
|
|
4303
|
+
for (const src of sources) {
|
|
4304
|
+
const st = sectionState.get(src.navKind);
|
|
4305
|
+
const meta = taxonomy.meta(src.navKind);
|
|
4306
|
+
const all = st.shaped;
|
|
4307
|
+
const filtered = _query ? all.filter((s) => _matchesShaped(s, _query)) : all;
|
|
4308
|
+
const total = filtered.length;
|
|
4309
|
+
let view;
|
|
4310
|
+
let page = 0;
|
|
4311
|
+
let pages = 1;
|
|
4312
|
+
if (_query) {
|
|
4313
|
+
view = filtered;
|
|
4314
|
+
} else {
|
|
4315
|
+
pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
4316
|
+
page = Math.min(st.page, pages - 1);
|
|
4317
|
+
const start = page * PAGE_SIZE;
|
|
4318
|
+
view = filtered.slice(start, start + PAGE_SIZE);
|
|
4319
|
+
}
|
|
4320
|
+
for (const shaped of view) {
|
|
4321
|
+
_visibleItems.push({ navKind: src.navKind, shaped });
|
|
4322
|
+
}
|
|
4323
|
+
sections.push({ src, meta, view, total, page, pages });
|
|
4324
|
+
}
|
|
4325
|
+
if (_cursor >= _visibleItems.length) {
|
|
4326
|
+
_cursor = Math.max(0, _visibleItems.length - 1);
|
|
4327
|
+
}
|
|
4328
|
+
let flatIdx = 0;
|
|
4329
|
+
bodyEl.innerHTML = sections.map((s) => {
|
|
4330
|
+
const items = s.view.map((shaped) => {
|
|
4331
|
+
const idx = flatIdx++;
|
|
4332
|
+
const label = shaped.label || shaped.id || "";
|
|
4333
|
+
const hint = shaped.hint || "";
|
|
4334
|
+
return `
|
|
4335
|
+
<li class="twm-tile-tabmenu__item${idx === _cursor ? " twm-tile-tabmenu__item--cursor" : ""}"
|
|
4336
|
+
data-flat-idx="${idx}">
|
|
4337
|
+
<span class="twm-tile-tabmenu__item-label">${_esc7(label)}</span>
|
|
4338
|
+
${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc7(hint)}</span>` : ""}
|
|
4339
|
+
</li>
|
|
4340
|
+
`;
|
|
4341
|
+
}).join("");
|
|
4342
|
+
return `
|
|
4343
|
+
<section class="twm-tile-tabmenu__section"
|
|
4344
|
+
data-section="${_esc7(s.src.navKind)}">
|
|
4345
|
+
<header class="twm-tile-tabmenu__section-head">
|
|
4346
|
+
<span class="material-symbols-outlined">${_esc7(s.meta?.icon || "arrow_right")}</span>
|
|
4347
|
+
<span class="twm-tile-tabmenu__section-label">${_esc7(s.meta?.label || s.src.navKind)}</span>
|
|
4348
|
+
<span class="twm-tile-tabmenu__section-count">${s.total}</span>
|
|
4349
|
+
</header>
|
|
4350
|
+
${s.view.length === 0 ? `
|
|
4351
|
+
<div class="twm-tile-tabmenu__empty">${_query ? "No matches." : "\u2014 none \u2014"}</div>
|
|
4352
|
+
` : `<ul class="twm-tile-tabmenu__list">${items}</ul>`}
|
|
4353
|
+
${!_query && s.pages > 1 ? `
|
|
4354
|
+
<div class="twm-tile-tabmenu__pager">
|
|
4355
|
+
<button type="button" data-action="prev"
|
|
4356
|
+
data-section="${_esc7(s.src.navKind)}"
|
|
4357
|
+
${s.page === 0 ? "disabled" : ""}><</button>
|
|
4358
|
+
<span>${s.page + 1} / ${s.pages}</span>
|
|
4359
|
+
<button type="button" data-action="next"
|
|
4360
|
+
data-section="${_esc7(s.src.navKind)}"
|
|
4361
|
+
${s.page >= s.pages - 1 ? "disabled" : ""}>></button>
|
|
4362
|
+
</div>
|
|
4363
|
+
` : ""}
|
|
4364
|
+
</section>
|
|
4365
|
+
`;
|
|
4366
|
+
}).join("");
|
|
4367
|
+
bodyEl.querySelectorAll(".twm-tile-tabmenu__item").forEach((li) => {
|
|
4368
|
+
const idx = Number(li.dataset.flatIdx);
|
|
4369
|
+
li.addEventListener("mousemove", () => {
|
|
4370
|
+
if (_cursor !== idx) {
|
|
4371
|
+
_cursor = idx;
|
|
4372
|
+
_paintCursor();
|
|
4373
|
+
}
|
|
4374
|
+
});
|
|
4375
|
+
li.addEventListener("click", () => {
|
|
4376
|
+
_cursor = idx;
|
|
4377
|
+
pickCursor();
|
|
4378
|
+
});
|
|
4379
|
+
});
|
|
4380
|
+
bodyEl.querySelectorAll('[data-action="prev"], [data-action="next"]').forEach((btn) => {
|
|
4381
|
+
btn.addEventListener("click", () => {
|
|
4382
|
+
const navKind = btn.dataset.section;
|
|
4383
|
+
const st = sectionState.get(navKind);
|
|
4384
|
+
if (!st) return;
|
|
4385
|
+
st.page += btn.dataset.action === "next" ? 1 : -1;
|
|
4386
|
+
_render();
|
|
4387
|
+
});
|
|
4388
|
+
});
|
|
4389
|
+
};
|
|
4390
|
+
const _paintCursor = () => {
|
|
4391
|
+
bodyEl.querySelectorAll(".twm-tile-tabmenu__item").forEach((li) => {
|
|
4392
|
+
const idx = Number(li.dataset.flatIdx);
|
|
4393
|
+
const on = idx === _cursor;
|
|
4394
|
+
li.classList.toggle("twm-tile-tabmenu__item--cursor", on);
|
|
4395
|
+
if (on) li.scrollIntoView({ block: "nearest" });
|
|
4396
|
+
});
|
|
4397
|
+
};
|
|
4398
|
+
(async () => {
|
|
4399
|
+
await Promise.all(sources.map(async (src) => {
|
|
4400
|
+
const st = sectionState.get(src.navKind);
|
|
4401
|
+
if (!st) return;
|
|
4402
|
+
st.rows = await entities.loadOne(src.navKind, api);
|
|
4403
|
+
st.shaped = entities.shapeRows(src.navKind, st.rows);
|
|
4404
|
+
}));
|
|
4405
|
+
_render();
|
|
4406
|
+
if (_visibleItems.length > 0) {
|
|
4407
|
+
_cursor = 0;
|
|
4408
|
+
_paintCursor();
|
|
4409
|
+
}
|
|
4410
|
+
})();
|
|
4411
|
+
searchInput.focus();
|
|
4412
|
+
return close;
|
|
4413
|
+
}
|
|
4414
|
+
function _matchesShaped(shaped, query) {
|
|
4415
|
+
if (!shaped) return false;
|
|
4416
|
+
const id = String(shaped.id ?? "").toLowerCase();
|
|
4417
|
+
const label = String(shaped.label ?? "").toLowerCase();
|
|
4418
|
+
const hint = String(shaped.hint ?? "").toLowerCase();
|
|
4419
|
+
return id.includes(query) || label.includes(query) || hint.includes(query);
|
|
4420
|
+
}
|
|
4421
|
+
function _esc7(s) {
|
|
4422
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
4423
|
+
"&": "&",
|
|
4424
|
+
"<": "<",
|
|
4425
|
+
">": ">",
|
|
4426
|
+
'"': """,
|
|
4427
|
+
"'": "'"
|
|
4428
|
+
})[c]);
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
// src/tiling/shell.js
|
|
4432
|
+
async function createShell({
|
|
4433
|
+
root,
|
|
4434
|
+
taxonomy,
|
|
4435
|
+
entities,
|
|
4436
|
+
content,
|
|
4437
|
+
host = null,
|
|
4438
|
+
api = null,
|
|
4439
|
+
eventBus = null,
|
|
4440
|
+
logger = null,
|
|
4441
|
+
tableStore = null,
|
|
4442
|
+
events = {},
|
|
4443
|
+
rootCrumb = null,
|
|
4444
|
+
palette: paletteCfg = {},
|
|
4445
|
+
chrome = {}
|
|
4446
|
+
} = {}) {
|
|
4447
|
+
if (!root || typeof root.appendChild !== "function") {
|
|
4448
|
+
throw new TypeError("createShell: `root` must be an element");
|
|
4449
|
+
}
|
|
4450
|
+
if (!taxonomy) throw new Error("createShell: a taxonomy is required");
|
|
4451
|
+
if (!entities) throw new Error("createShell: an entity catalog is required");
|
|
4452
|
+
const log = logger ?? { info() {
|
|
4453
|
+
}, warn() {
|
|
4454
|
+
}, error() {
|
|
4455
|
+
}, debug() {
|
|
4456
|
+
} };
|
|
4457
|
+
const registry = createContentRegistry(content);
|
|
4458
|
+
let wm = null;
|
|
4459
|
+
let topNavEl = null;
|
|
4460
|
+
let desktopsEl = null;
|
|
4461
|
+
let toggles = null;
|
|
4462
|
+
const syncChrome = () => {
|
|
4463
|
+
if (!wm) return;
|
|
4464
|
+
syncPanelToggles(toggles, wm);
|
|
4465
|
+
syncDesktopBar(desktopsEl, wm);
|
|
4466
|
+
syncTopNav(topNavEl, wm);
|
|
4467
|
+
};
|
|
4468
|
+
wm = new WindowManager({
|
|
4469
|
+
rootEl: root,
|
|
4470
|
+
content: registry,
|
|
4471
|
+
api,
|
|
4472
|
+
eventBus,
|
|
4473
|
+
host,
|
|
4474
|
+
taxonomy,
|
|
4475
|
+
events,
|
|
4476
|
+
// `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
|
|
4477
|
+
// spreads it into every content factory, which is how the breadcrumb
|
|
4478
|
+
// gets `taxonomy` + `rootCrumb` without a content factory knowing they
|
|
4479
|
+
// exist. The two `onTile*Menu` keys are read with OPTIONAL CHAINING in
|
|
4480
|
+
// tile_renderer — drop one and right-click silently does nothing.
|
|
4481
|
+
ctx: {
|
|
4482
|
+
api,
|
|
4483
|
+
eventBus,
|
|
4484
|
+
host,
|
|
4485
|
+
tableStore,
|
|
4486
|
+
taxonomy,
|
|
4487
|
+
entities,
|
|
4488
|
+
rootCrumb,
|
|
4489
|
+
events,
|
|
4490
|
+
onTileContextMenu: (leafId, x, y) => _tileContextMenu(wm, leafId, x, y),
|
|
4491
|
+
onTileTabMenu: (leafId, x, y) => _tileTabMenu(wm, leafId, x, y)
|
|
4492
|
+
},
|
|
4493
|
+
onChange: () => syncChrome()
|
|
4494
|
+
});
|
|
4495
|
+
const palette = createCommandPalette({
|
|
4496
|
+
wm,
|
|
4497
|
+
api,
|
|
4498
|
+
taxonomy,
|
|
4499
|
+
catalog: entities,
|
|
4500
|
+
...paletteCfg
|
|
4501
|
+
});
|
|
4502
|
+
installKeymap({ wm, palette });
|
|
4503
|
+
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
4504
|
+
topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
4505
|
+
desktopsEl = mountDesktopBar(chrome.desktops, wm);
|
|
4506
|
+
toggles = bindPanelToggles(chrome.panelToggles, wm);
|
|
4507
|
+
eventBus?.on?.("wm:changed", syncChrome);
|
|
4508
|
+
await wm.load();
|
|
4509
|
+
syncChrome();
|
|
4510
|
+
log.info?.("shell mounted");
|
|
4511
|
+
return Object.freeze({
|
|
4512
|
+
wm,
|
|
4513
|
+
palette,
|
|
4514
|
+
taxonomy,
|
|
4515
|
+
entities,
|
|
4516
|
+
root,
|
|
4517
|
+
content: registry,
|
|
4518
|
+
chrome: Object.freeze({
|
|
4519
|
+
paletteButtonEl: paletteBtn,
|
|
4520
|
+
topNavEl,
|
|
4521
|
+
desktopsEl,
|
|
4522
|
+
panelToggles: toggles
|
|
4523
|
+
}),
|
|
4524
|
+
dispose: () => {
|
|
4525
|
+
try {
|
|
4526
|
+
eventBus?.off?.("wm:changed", syncChrome);
|
|
4527
|
+
} catch {
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
});
|
|
4531
|
+
}
|
|
4532
|
+
function mountTopNav(hostEl, taxonomy, wm) {
|
|
4533
|
+
if (!hostEl) return null;
|
|
4534
|
+
hostEl.innerHTML = "";
|
|
4535
|
+
hostEl.classList.add("twm-top-nav");
|
|
4536
|
+
for (const k of taxonomy.topNavEntries()) {
|
|
4537
|
+
const btn = document.createElement("button");
|
|
4538
|
+
btn.className = "twm-top-nav__btn twm-has-tooltip";
|
|
4539
|
+
btn.dataset.kind = k.kind;
|
|
4540
|
+
btn.dataset.tooltip = k.label;
|
|
4541
|
+
btn.dataset.tooltipPlacement = "bottom";
|
|
4542
|
+
btn.setAttribute("aria-label", k.label);
|
|
4543
|
+
btn.innerHTML = `
|
|
4544
|
+
<span class="material-symbols-outlined twm-top-nav__icon">${k.icon}</span>
|
|
4545
|
+
<span class="twm-top-nav__label">${k.label}</span>
|
|
4546
|
+
`;
|
|
4547
|
+
hostEl.appendChild(btn);
|
|
4548
|
+
}
|
|
4549
|
+
hostEl.addEventListener("click", (e) => {
|
|
4550
|
+
const btn = e.target.closest("[data-kind]");
|
|
4551
|
+
if (!btn) return;
|
|
4552
|
+
wm.openInPrimary(btn.dataset.kind);
|
|
4553
|
+
});
|
|
4554
|
+
return hostEl;
|
|
4555
|
+
}
|
|
4556
|
+
function syncTopNav(hostEl, wm) {
|
|
4557
|
+
if (!hostEl) return;
|
|
4558
|
+
const tree = wm.desktops.active().tree;
|
|
4559
|
+
const primaryId = tree.primaryLeafId();
|
|
4560
|
+
const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
|
|
4561
|
+
const topNavKind = wm.taxonomy.topNavFor(primaryKind) || primaryKind;
|
|
4562
|
+
hostEl.querySelectorAll("[data-kind]").forEach((b) => {
|
|
4563
|
+
b.classList.toggle("twm-top-nav__btn--on", b.dataset.kind === topNavKind);
|
|
4564
|
+
});
|
|
4565
|
+
}
|
|
4566
|
+
function mountPaletteButton(hostEl, palette) {
|
|
4567
|
+
if (!hostEl) return null;
|
|
4568
|
+
const existing = hostEl.querySelector("#twm-palette-btn");
|
|
4569
|
+
if (existing) return existing;
|
|
4570
|
+
const btn = document.createElement("button");
|
|
4571
|
+
btn.id = "twm-palette-btn";
|
|
4572
|
+
btn.className = "twm-panel-toggle-btn twm-has-tooltip";
|
|
4573
|
+
btn.dataset.tooltip = "Command palette (Ctrl+K)";
|
|
4574
|
+
btn.dataset.tooltipPlacement = "bottom";
|
|
4575
|
+
btn.setAttribute("aria-label", "Open command palette");
|
|
4576
|
+
btn.innerHTML = '<span class="material-symbols-outlined">menu_open</span>';
|
|
4577
|
+
btn.addEventListener("click", () => palette.toggle());
|
|
4578
|
+
hostEl.insertBefore(btn, hostEl.firstChild);
|
|
4579
|
+
return btn;
|
|
4580
|
+
}
|
|
4581
|
+
function bindPanelToggles(map, wm) {
|
|
4582
|
+
const fresh = {};
|
|
4583
|
+
for (const side of ["left", "right", "bottom"]) {
|
|
4584
|
+
const btn = map?.[side];
|
|
4585
|
+
if (!btn) continue;
|
|
4586
|
+
const el = btn.cloneNode(true);
|
|
4587
|
+
el.classList.remove("active", "pinned", "disabled");
|
|
4588
|
+
el.disabled = false;
|
|
4589
|
+
el.removeAttribute("aria-disabled");
|
|
4590
|
+
el.removeAttribute("aria-pressed");
|
|
4591
|
+
btn.replaceWith(el);
|
|
4592
|
+
el.addEventListener("click", () => wm.togglePanel(side));
|
|
4593
|
+
fresh[side] = el;
|
|
4594
|
+
}
|
|
4595
|
+
return fresh;
|
|
4596
|
+
}
|
|
4597
|
+
function syncPanelToggles(map, wm) {
|
|
4598
|
+
for (const side of ["left", "right", "bottom"]) {
|
|
4599
|
+
const btn = map?.[side];
|
|
4600
|
+
if (!btn) continue;
|
|
4601
|
+
btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
function mountDesktopBar(hostEl, wm) {
|
|
4605
|
+
if (!hostEl) return null;
|
|
4606
|
+
let el = hostEl.querySelector("#twm-desktops");
|
|
4607
|
+
if (!el) {
|
|
4608
|
+
el = document.createElement("div");
|
|
4609
|
+
el.id = "twm-desktops";
|
|
4610
|
+
el.className = "twm-desktops";
|
|
4611
|
+
hostEl.appendChild(el);
|
|
4612
|
+
}
|
|
4613
|
+
el.addEventListener("click", (e) => {
|
|
4614
|
+
const btn = e.target.closest("[data-desktop]");
|
|
4615
|
+
if (!btn) return;
|
|
4616
|
+
const v = btn.dataset.desktop;
|
|
4617
|
+
if (v === "+") {
|
|
4618
|
+
wm.addDesktop();
|
|
4619
|
+
syncDesktopBar(el, wm);
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
wm.switchDesktop(Number(v));
|
|
4623
|
+
});
|
|
4624
|
+
el.addEventListener("dblclick", async (e) => {
|
|
4625
|
+
const btn = e.target.closest("[data-desktop]");
|
|
4626
|
+
if (!btn || btn.dataset.desktop === "+") return;
|
|
4627
|
+
e.preventDefault();
|
|
4628
|
+
const idx = Number(btn.dataset.desktop);
|
|
4629
|
+
const d = wm.desktops.desktops[idx];
|
|
4630
|
+
if (!d) return;
|
|
4631
|
+
const result = await openForm({
|
|
4632
|
+
title: "Rename desktop",
|
|
4633
|
+
fields: [{
|
|
4634
|
+
name: "label",
|
|
4635
|
+
label: "Name",
|
|
4636
|
+
type: "text",
|
|
4637
|
+
required: true,
|
|
4638
|
+
hint: "Up to 24 characters. Shown in the bottom-bar chip and the Alt+N tooltip."
|
|
4639
|
+
}],
|
|
4640
|
+
defaults: { label: d.label },
|
|
4641
|
+
submitLabel: "Rename"
|
|
4642
|
+
});
|
|
4643
|
+
if (!result || !result.label) return;
|
|
4644
|
+
d.label = String(result.label).slice(0, 24);
|
|
4645
|
+
syncDesktopBar(el, wm);
|
|
4646
|
+
wm._persist();
|
|
4647
|
+
});
|
|
4648
|
+
el.addEventListener("contextmenu", (e) => {
|
|
4649
|
+
const btn = e.target.closest("[data-desktop]");
|
|
4650
|
+
if (!btn || btn.dataset.desktop === "+") return;
|
|
4651
|
+
e.preventDefault();
|
|
4652
|
+
const idx = Number(btn.dataset.desktop);
|
|
4653
|
+
const m = wm.desktops;
|
|
4654
|
+
const d = m.desktops[idx];
|
|
4655
|
+
const canDelete = m.desktops.length > 1;
|
|
4656
|
+
showContextMenu(e.clientX, e.clientY, [
|
|
4657
|
+
{
|
|
4658
|
+
label: `Switch to ${d.label}`,
|
|
4659
|
+
icon: "desktop_windows",
|
|
4660
|
+
action: "switch",
|
|
4661
|
+
disabled: idx === m.activeIdx
|
|
4662
|
+
},
|
|
4663
|
+
{ label: "Rename\u2026", icon: "edit", action: "rename" },
|
|
4664
|
+
{ separator: true },
|
|
4665
|
+
{ label: "New desktop", icon: "add", action: "add" },
|
|
4666
|
+
{
|
|
4667
|
+
label: "Delete desktop",
|
|
4668
|
+
icon: "delete",
|
|
4669
|
+
action: "delete",
|
|
4670
|
+
danger: true,
|
|
4671
|
+
disabled: !canDelete
|
|
4672
|
+
}
|
|
4673
|
+
], async (action) => {
|
|
4674
|
+
if (action === "switch") wm.switchDesktop(idx);
|
|
4675
|
+
else if (action === "rename") {
|
|
4676
|
+
const result = await openForm({
|
|
4677
|
+
title: "Rename desktop",
|
|
4678
|
+
fields: [{ name: "label", label: "Name", type: "text", required: true }],
|
|
4679
|
+
defaults: { label: d.label },
|
|
4680
|
+
submitLabel: "Rename"
|
|
4681
|
+
});
|
|
4682
|
+
if (!result || !result.label) return;
|
|
4683
|
+
d.label = String(result.label).slice(0, 24);
|
|
4684
|
+
syncDesktopBar(el, wm);
|
|
4685
|
+
wm._persist();
|
|
4686
|
+
} else if (action === "add") {
|
|
4687
|
+
wm.addDesktop();
|
|
4688
|
+
syncDesktopBar(el, wm);
|
|
4689
|
+
} else if (action === "delete") {
|
|
4690
|
+
wm.removeDesktop(idx);
|
|
4691
|
+
syncDesktopBar(el, wm);
|
|
4692
|
+
}
|
|
4693
|
+
});
|
|
4694
|
+
});
|
|
4695
|
+
return el;
|
|
4696
|
+
}
|
|
4697
|
+
function syncDesktopBar(el, wm) {
|
|
4698
|
+
if (!el) return;
|
|
4699
|
+
const m = wm.desktops;
|
|
4700
|
+
el.innerHTML = m.desktops.map((d, i) => `
|
|
4701
|
+
<button class="twm-desk__btn${i === m.activeIdx ? " twm-desk__btn--on" : ""}"
|
|
4702
|
+
data-desktop="${i}"
|
|
4703
|
+
title="Desktop ${d.label} (Alt+${i + 1}) \xB7 right-click to delete">
|
|
4704
|
+
${d.label}
|
|
4705
|
+
</button>`).join("") + `<button class="twm-desk__btn twm-desk__btn--add"
|
|
4706
|
+
data-desktop="+" title="Add desktop">+</button>`;
|
|
4707
|
+
}
|
|
4708
|
+
function _tileTabMenu(wm, leafId, x, y) {
|
|
4709
|
+
const tree = wm.desktops.active().tree;
|
|
4710
|
+
const leaf = tree.get(leafId);
|
|
4711
|
+
if (!leaf) return;
|
|
4712
|
+
const kind = leaf.content?.kind || wm.taxonomy.root;
|
|
4713
|
+
openTileTabMenu({
|
|
4714
|
+
x,
|
|
4715
|
+
y,
|
|
4716
|
+
leafKind: kind,
|
|
4717
|
+
api: wm.api,
|
|
4718
|
+
taxonomy: wm.taxonomy,
|
|
4719
|
+
entities: wm.ctx.entities,
|
|
4720
|
+
onPick: (navKind, shaped) => {
|
|
4721
|
+
tree.appendLeafTab(leafId, {
|
|
4722
|
+
kind: navKind,
|
|
4723
|
+
props: { id: shaped.id, label: shaped.label }
|
|
4724
|
+
}, shaped.label || shaped.id);
|
|
4725
|
+
tree.focus(leafId);
|
|
4726
|
+
wm.renderer.render();
|
|
4727
|
+
wm._persist?.();
|
|
4728
|
+
wm._notifyChange?.("tab-open-from-menu");
|
|
4729
|
+
}
|
|
4730
|
+
});
|
|
4731
|
+
}
|
|
4732
|
+
function _tileContextMenu(wm, leafId, x, y) {
|
|
4733
|
+
const tree = wm.desktops.active().tree;
|
|
4734
|
+
const leaf = tree.get(leafId);
|
|
4735
|
+
if (!leaf) return;
|
|
4736
|
+
const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
|
|
4737
|
+
tree.focus(leafId);
|
|
4738
|
+
wm.renderer._updateFocusClasses();
|
|
4739
|
+
const items = [
|
|
4740
|
+
{ label: "Split horizontally", icon: "splitscreen_vertical_add", action: "split-h" },
|
|
4741
|
+
{ label: "Split vertically", icon: "splitscreen_add", action: "split-v" },
|
|
4742
|
+
{ separator: true },
|
|
4743
|
+
{
|
|
4744
|
+
label: "Open in new tab",
|
|
4745
|
+
icon: "tab",
|
|
4746
|
+
action: "open-tab",
|
|
4747
|
+
disabled: isPanel || !leaf.content
|
|
4748
|
+
},
|
|
4749
|
+
{
|
|
4750
|
+
label: "Open in new window",
|
|
4751
|
+
icon: "open_in_full",
|
|
4752
|
+
action: "open-window",
|
|
4753
|
+
disabled: isPanel || !leaf.content
|
|
4754
|
+
},
|
|
4755
|
+
{
|
|
4756
|
+
label: "Promote to window",
|
|
4757
|
+
icon: "open_in_new",
|
|
4758
|
+
action: "promote",
|
|
4759
|
+
disabled: isPanel || !leaf.content
|
|
4760
|
+
}
|
|
4761
|
+
];
|
|
4762
|
+
if (wm.desktops.desktops.length > 1 && !isPanel) {
|
|
4763
|
+
for (const [i, d] of wm.desktops.desktops.entries()) {
|
|
4764
|
+
if (i === wm.desktops.activeIdx) continue;
|
|
4765
|
+
items.push({
|
|
4766
|
+
label: `Move to desktop ${d.label}`,
|
|
4767
|
+
icon: "sweep",
|
|
4768
|
+
action: `move:${i}`
|
|
4769
|
+
});
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
items.push({ separator: true });
|
|
4773
|
+
items.push({
|
|
4774
|
+
label: "Close tile",
|
|
4775
|
+
icon: "close",
|
|
4776
|
+
action: "close",
|
|
4777
|
+
danger: true,
|
|
4778
|
+
disabled: isPanel
|
|
4779
|
+
});
|
|
4780
|
+
showContextMenu(x, y, items, (action) => {
|
|
4781
|
+
if (action === "split-h") wm.split("h");
|
|
4782
|
+
else if (action === "split-v") wm.split("v");
|
|
4783
|
+
else if (action === "open-tab") {
|
|
4784
|
+
const c = leaf.content;
|
|
4785
|
+
if (c) wm.openInTabFromContext({ leafId }, c.kind, c.props || {});
|
|
4786
|
+
} else if (action === "open-window") {
|
|
4787
|
+
const c = leaf.content;
|
|
4788
|
+
if (c) wm.navigate(c.kind, c.props || {}, { target: "window" });
|
|
4789
|
+
} else if (action === "promote") wm.toggleManagedFocused();
|
|
4790
|
+
else if (action === "close") wm.closeFocused();
|
|
4791
|
+
else if (action?.startsWith?.("move:")) {
|
|
4792
|
+
wm.moveFocusedToDesktop(Number(action.slice(5)));
|
|
4793
|
+
}
|
|
4794
|
+
});
|
|
4795
|
+
}
|
|
4796
|
+
export {
|
|
4797
|
+
DesktopManager,
|
|
4798
|
+
PLACEHOLDER_KIND,
|
|
4799
|
+
PanelKeyRouter,
|
|
4800
|
+
TileRenderer,
|
|
4801
|
+
TileTree,
|
|
4802
|
+
WindowManager,
|
|
4803
|
+
actionsCellRenderer,
|
|
4804
|
+
attachLandingKeyboardNav,
|
|
4805
|
+
attachLandingShell,
|
|
4806
|
+
attachLandingTableBehavior,
|
|
4807
|
+
createCommandPalette,
|
|
4808
|
+
createContentRegistry,
|
|
4809
|
+
createEntityCatalog,
|
|
4810
|
+
createPageFactories,
|
|
4811
|
+
createShell,
|
|
4812
|
+
createTaxonomy,
|
|
4813
|
+
getPanelKeyRouter,
|
|
4814
|
+
installKeymap,
|
|
4815
|
+
installPanelKeyRouter,
|
|
4816
|
+
loadDesktops,
|
|
4817
|
+
makeLeaf,
|
|
4818
|
+
makeLoadingOverlay,
|
|
4819
|
+
makeSplit,
|
|
4820
|
+
mountLandingActions,
|
|
4821
|
+
mountLandingShell,
|
|
4822
|
+
mountNavPanel,
|
|
4823
|
+
mountTileBreadcrumb,
|
|
4824
|
+
openTileTabMenu,
|
|
4825
|
+
registerPanelKeys,
|
|
4826
|
+
saveDesktops,
|
|
4827
|
+
uninstallPanelKeyRouter,
|
|
4828
|
+
wireLandingPaneFocus
|
|
4829
|
+
};
|
|
4830
|
+
//# sourceMappingURL=wm.js.map
|