flexdesk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/css/base.css +1484 -181
- package/css/flexdesk.css +1311 -18
- package/css/overrides.css +44 -0
- package/css/tokens.css +45 -0
- package/dist/charts.js +5 -3
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
- package/dist/chunk-ELXVW542.js.map +7 -0
- package/dist/chunk-LH5TSOZW.js +1237 -0
- package/dist/chunk-LH5TSOZW.js.map +7 -0
- package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
- package/dist/chunk-O5OHMWBB.js.map +7 -0
- package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
- package/dist/chunk-QIU5S2RU.js.map +7 -0
- package/dist/chunk-QNQHQ24V.js +408 -0
- package/dist/chunk-QNQHQ24V.js.map +7 -0
- package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
- package/dist/chunk-XKDTIT4Q.js.map +7 -0
- package/dist/editor.js +3 -380
- package/dist/editor.js.map +3 -3
- package/dist/flexdesk.css +1311 -18
- package/dist/tiles.js +168 -41
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +45 -0
- package/dist/widgets.js +44 -14
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2983 -142
- package/dist/wm.js.map +4 -4
- package/package.json +3 -2
- package/src/charts/chart_types.js +167 -0
- package/src/charts/plotly_wrapper.js +178 -10
- package/src/editor/notebook_tab_bar.js +39 -3
- package/src/tiles/tile_base.js +143 -35
- package/src/tiles/tile_grid.js +52 -1
- package/src/tiling/command_palette.js +71 -18
- package/src/tiling/desktops.js +36 -12
- package/src/tiling/keymap.js +24 -4
- package/src/tiling/shell.js +135 -24
- package/src/tiling/tab_strip.js +184 -0
- package/src/tiling/tile_breadcrumb.js +34 -2
- package/src/tiling/tile_renderer.js +1386 -21
- package/src/tiling/tile_tab_menu.js +101 -0
- package/src/tiling/tile_tree.js +82 -0
- package/src/tiling/wm.js +2352 -74
- package/src/ui/components/action_dropdown.js +34 -3
- package/src/ui/components/autocomplete_field.js +65 -13
- package/src/ui/components/context_menu.js +79 -8
- package/src/ui/components/data_table.js +508 -84
- package/src/ui/components/managed_window.js +928 -36
- package/src/ui/components/modal.js +214 -8
- package/dist/chunk-CT4YXXLP.js.map +0 -7
- package/dist/chunk-DRYCDMEG.js.map +0 -7
- package/dist/chunk-DVU44T77.js.map +0 -7
- package/dist/chunk-TLZUUFOE.js.map +0 -7
- package/dist/chunk-UCJ2WD4D.js +0 -625
- package/dist/chunk-UCJ2WD4D.js.map +0 -7
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSetting
|
|
3
|
+
} from "./chunk-FL5KFNQH.js";
|
|
4
|
+
|
|
5
|
+
// src/ui/components/managed_window.js
|
|
6
|
+
var STORAGE_KEY = "ecosim.managedWindows.v1";
|
|
7
|
+
var BASE_Z_INDEX = 6e3;
|
|
8
|
+
var MAX_Z_INDEX = 6999;
|
|
9
|
+
var TOP_BAR_HEIGHT = 35;
|
|
10
|
+
var BOTTOM_BAR_HEIGHT = 22;
|
|
11
|
+
var SNAP_EDGE = 12;
|
|
12
|
+
var DEFAULT_ICON = "web_asset";
|
|
13
|
+
var _zIndexCounter = 0;
|
|
14
|
+
var _activeWindows = /* @__PURE__ */ new Map();
|
|
15
|
+
function _collectFocusable(root) {
|
|
16
|
+
if (!root) return [];
|
|
17
|
+
const sel = [
|
|
18
|
+
"a[href]",
|
|
19
|
+
"button:not([disabled])",
|
|
20
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
21
|
+
"select:not([disabled])",
|
|
22
|
+
"textarea:not([disabled])",
|
|
23
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
24
|
+
].join(",");
|
|
25
|
+
return Array.from(root.querySelectorAll(sel)).filter((el) => {
|
|
26
|
+
if (el.hidden) return false;
|
|
27
|
+
if (el.closest("[hidden]")) return false;
|
|
28
|
+
const r = el.getBoundingClientRect();
|
|
29
|
+
return r.width > 0 || r.height > 0;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
var _stateCache = null;
|
|
33
|
+
function _loadState() {
|
|
34
|
+
if (_stateCache) return _stateCache;
|
|
35
|
+
try {
|
|
36
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
37
|
+
_stateCache = raw ? JSON.parse(raw) : {};
|
|
38
|
+
} catch {
|
|
39
|
+
_stateCache = {};
|
|
40
|
+
}
|
|
41
|
+
return _stateCache;
|
|
42
|
+
}
|
|
43
|
+
function _saveState(state) {
|
|
44
|
+
_stateCache = state;
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn("[ManagedWindow] Failed to save state:", err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function _getWindowState(id) {
|
|
52
|
+
const state = _loadState();
|
|
53
|
+
return state[id] || null;
|
|
54
|
+
}
|
|
55
|
+
function _setWindowState(id, windowState) {
|
|
56
|
+
const state = _loadState();
|
|
57
|
+
state[id] = windowState;
|
|
58
|
+
_saveState(state);
|
|
59
|
+
}
|
|
60
|
+
var ManagedWindow = class {
|
|
61
|
+
/**
|
|
62
|
+
* @param {Object} options
|
|
63
|
+
* @param {string} options.id - Unique window ID for persistence
|
|
64
|
+
* @param {string} options.title - Window title
|
|
65
|
+
* @param {string} [options.icon] - Material Symbols icon name (default: 'web_asset')
|
|
66
|
+
* @param {HTMLElement|Function} options.content - Content element or render function
|
|
67
|
+
* @param {number} [options.minWidth=400] - Minimum width
|
|
68
|
+
* @param {number} [options.minHeight=300] - Minimum height
|
|
69
|
+
* @param {number} [options.defaultWidth=600] - Default width
|
|
70
|
+
* @param {number} [options.defaultHeight=400] - Default height
|
|
71
|
+
* @param {boolean} [options.canMinimize=true] - Whether minimize button is shown
|
|
72
|
+
* @param {boolean} [options.canMaximize=true] - Whether maximize button is shown
|
|
73
|
+
* @param {boolean} [options.canResize=true] - Whether window can be resized
|
|
74
|
+
* @param {boolean} [options.canDrag=true] - Whether window can be dragged
|
|
75
|
+
* @param {boolean} [options.modal=false] - Whether to show backdrop
|
|
76
|
+
* @param {Function} [options.onClose] - Callback when window is closed
|
|
77
|
+
* @param {Function} [options.beforeClose] - Guard called before close. Return false (or a Promise resolving to false) to prevent closing.
|
|
78
|
+
* @param {Function} [options.onMinimize] - Callback when window is minimized
|
|
79
|
+
* @param {HTMLElement} [options.container] - Mount point. Defaults to
|
|
80
|
+
* `document.body` (every call site that exists today). When given, the
|
|
81
|
+
* window is positioned and CLAMPED inside that element instead of the
|
|
82
|
+
* viewport, and its taskbar events carry the container so a per-panel
|
|
83
|
+
* taskbar can filter on them.
|
|
84
|
+
*
|
|
85
|
+
* The container MUST establish a containing block with
|
|
86
|
+
* `position: relative` or `position: absolute` — and with NOTHING else.
|
|
87
|
+
* `transform`, `filter`, `contain` and `will-change` also create a
|
|
88
|
+
* containing block, and they additionally trap `position: fixed`
|
|
89
|
+
* descendants. DataTable's filter dropdown (`position: fixed;
|
|
90
|
+
* z-index: 10001`) and the autocomplete dropdown deliberately ESCAPE
|
|
91
|
+
* their tile to the viewport; under a transformed ancestor they become
|
|
92
|
+
* container-relative and get clipped by `overflow: hidden`. The symptom
|
|
93
|
+
* is "the filter dropdown is cut in half" and the cause is three files
|
|
94
|
+
* away.
|
|
95
|
+
* @param {Array} [options.titlebarButtons] - Extra buttons left of
|
|
96
|
+
* minimize: `{icon, title, onClick}`.
|
|
97
|
+
*/
|
|
98
|
+
constructor(options) {
|
|
99
|
+
this.id = options.id;
|
|
100
|
+
this.title = options.title || "Window";
|
|
101
|
+
this.icon = options.icon || DEFAULT_ICON;
|
|
102
|
+
this.content = options.content;
|
|
103
|
+
this.minWidth = options.minWidth || 400;
|
|
104
|
+
this.minHeight = options.minHeight || 300;
|
|
105
|
+
this.defaultWidth = options.defaultWidth || 600;
|
|
106
|
+
this.defaultHeight = options.defaultHeight || 400;
|
|
107
|
+
this.canMinimize = options.canMinimize ?? true;
|
|
108
|
+
this.canMaximize = options.canMaximize ?? true;
|
|
109
|
+
this.canResize = options.canResize ?? true;
|
|
110
|
+
this.modal = options.modal ?? false;
|
|
111
|
+
this.backdropBlur = options.backdropBlur;
|
|
112
|
+
this.backdropOpacity = options.backdropOpacity;
|
|
113
|
+
this.canDrag = options.canDrag ?? !this.modal;
|
|
114
|
+
this.onClose = options.onClose;
|
|
115
|
+
this.beforeClose = options.beforeClose || null;
|
|
116
|
+
this.onMinimize = options.onMinimize;
|
|
117
|
+
this.container = options.container || null;
|
|
118
|
+
this.snap = (options.snap ?? false) && this.canDrag && this.canResize;
|
|
119
|
+
this.snapController = options.snapController || null;
|
|
120
|
+
this.dragHost = options.dragHost || null;
|
|
121
|
+
this.dragBounds = options.dragBounds || null;
|
|
122
|
+
this.onMaximize = options.onMaximize || null;
|
|
123
|
+
this.maximizeIcon = options.maximizeIcon || null;
|
|
124
|
+
this.maximizeTitle = options.maximizeTitle || "Maximize";
|
|
125
|
+
this._snapZone = null;
|
|
126
|
+
this._snapProbe = null;
|
|
127
|
+
this._escapeOrigin = null;
|
|
128
|
+
this._preSnapState = null;
|
|
129
|
+
this._snapPreviewEl = null;
|
|
130
|
+
this._minimizeTimer = null;
|
|
131
|
+
this._restoreTimer = null;
|
|
132
|
+
this.titlebarButtons = Array.isArray(options.titlebarButtons) ? options.titlebarButtons : [];
|
|
133
|
+
this.element = null;
|
|
134
|
+
this.backdropElement = null;
|
|
135
|
+
this.contentContainer = null;
|
|
136
|
+
this.isVisible = false;
|
|
137
|
+
this.isMinimized = false;
|
|
138
|
+
this.isMaximized = false;
|
|
139
|
+
this.zIndex = BASE_Z_INDEX;
|
|
140
|
+
this.x = 0;
|
|
141
|
+
this.y = 0;
|
|
142
|
+
const initial = this._bounds();
|
|
143
|
+
this.width = Math.min(this.defaultWidth, initial.width);
|
|
144
|
+
this.height = Math.min(this.defaultHeight, initial.height);
|
|
145
|
+
this._preMaximizeState = null;
|
|
146
|
+
this._dragState = null;
|
|
147
|
+
this._resizeState = null;
|
|
148
|
+
this._boundOnPointerMove = this._onPointerMove.bind(this);
|
|
149
|
+
this._boundOnPointerUp = this._onPointerUp.bind(this);
|
|
150
|
+
this._boundOnKeyDown = this._onKeyDown.bind(this);
|
|
151
|
+
_activeWindows.set(this.id, this);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Show the window.
|
|
155
|
+
*/
|
|
156
|
+
show() {
|
|
157
|
+
if (this.isVisible && !this.isMinimized) {
|
|
158
|
+
if (this.element && this.element.style.display === "none") {
|
|
159
|
+
this.element.style.display = "";
|
|
160
|
+
}
|
|
161
|
+
this.bringToFront();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!this.element) {
|
|
165
|
+
this._build();
|
|
166
|
+
this._restoreState();
|
|
167
|
+
}
|
|
168
|
+
if (this.isMinimized) {
|
|
169
|
+
this._restore();
|
|
170
|
+
} else {
|
|
171
|
+
this.element?.classList.toggle(
|
|
172
|
+
"twm-managed-window--maximized",
|
|
173
|
+
this.isMaximized
|
|
174
|
+
);
|
|
175
|
+
this._applyPosition();
|
|
176
|
+
const mount = this.container || document.body;
|
|
177
|
+
this.element.classList.toggle("twm-managed-window--contained", !!this.container);
|
|
178
|
+
mount.appendChild(this.element);
|
|
179
|
+
if (this.modal && this.backdropElement) {
|
|
180
|
+
this.backdropElement.classList.toggle(
|
|
181
|
+
"twm-managed-window__backdrop--contained",
|
|
182
|
+
!!this.container
|
|
183
|
+
);
|
|
184
|
+
mount.appendChild(this.backdropElement);
|
|
185
|
+
}
|
|
186
|
+
this._installContainerResizeObserver();
|
|
187
|
+
this.isVisible = true;
|
|
188
|
+
}
|
|
189
|
+
this.bringToFront();
|
|
190
|
+
document.addEventListener("keydown", this._boundOnKeyDown);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Hide/close the window.
|
|
194
|
+
* @param {{ force?: boolean }} [options] - Pass force:true to bypass the beforeClose guard.
|
|
195
|
+
*/
|
|
196
|
+
close({ force = false } = {}) {
|
|
197
|
+
if (!this.isVisible) return;
|
|
198
|
+
if (!force && this.beforeClose) {
|
|
199
|
+
const result = this.beforeClose();
|
|
200
|
+
if (result && typeof result.then === "function") {
|
|
201
|
+
result.then((allowed) => {
|
|
202
|
+
if (allowed !== false) this._doClose();
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (result === false) return;
|
|
207
|
+
}
|
|
208
|
+
this._doClose();
|
|
209
|
+
}
|
|
210
|
+
/** Internal close — always executes, no guard. */
|
|
211
|
+
_doClose() {
|
|
212
|
+
if (!this.isVisible) return;
|
|
213
|
+
this._saveCurrentState();
|
|
214
|
+
if (this.onClose) {
|
|
215
|
+
this.onClose();
|
|
216
|
+
}
|
|
217
|
+
if (this.element && this.element.parentNode) {
|
|
218
|
+
this.element.parentNode.removeChild(this.element);
|
|
219
|
+
}
|
|
220
|
+
if (this.backdropElement && this.backdropElement.parentNode) {
|
|
221
|
+
this.backdropElement.parentNode.removeChild(this.backdropElement);
|
|
222
|
+
}
|
|
223
|
+
this.isVisible = false;
|
|
224
|
+
this.isMinimized = false;
|
|
225
|
+
this._cancelMinimizeAnimation();
|
|
226
|
+
this._cancelRestoreAnimation();
|
|
227
|
+
this._clearTargetProperties();
|
|
228
|
+
document.removeEventListener("keydown", this._boundOnKeyDown);
|
|
229
|
+
this._teardownContainerResizeObserver();
|
|
230
|
+
window.dispatchEvent(new CustomEvent("managed-window-closed", {
|
|
231
|
+
detail: { id: this.id, container: this.container }
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Minimize to taskbar with animation.
|
|
236
|
+
*/
|
|
237
|
+
minimize() {
|
|
238
|
+
if (!this.isVisible || this.isMinimized) return;
|
|
239
|
+
this._saveCurrentState();
|
|
240
|
+
this.isMinimized = true;
|
|
241
|
+
if (this.onMinimize) {
|
|
242
|
+
this.onMinimize();
|
|
243
|
+
}
|
|
244
|
+
if (this.backdropElement) {
|
|
245
|
+
this.backdropElement.style.display = "none";
|
|
246
|
+
}
|
|
247
|
+
window.dispatchEvent(new CustomEvent("managed-window-minimized", {
|
|
248
|
+
detail: {
|
|
249
|
+
id: this.id,
|
|
250
|
+
title: this.title,
|
|
251
|
+
icon: this.icon,
|
|
252
|
+
container: this.container
|
|
253
|
+
}
|
|
254
|
+
}));
|
|
255
|
+
if (!this.element) return;
|
|
256
|
+
this._cancelRestoreAnimation();
|
|
257
|
+
if (!getSetting("window.animateMinimize", true)) {
|
|
258
|
+
this.element.style.display = "none";
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this._setMinimizeTargetProperties();
|
|
262
|
+
this.element.classList.add("twm-managed-window--minimizing");
|
|
263
|
+
this._minimizeTimer = setTimeout(() => {
|
|
264
|
+
this._minimizeTimer = null;
|
|
265
|
+
if (this.element) {
|
|
266
|
+
this.element.style.display = "none";
|
|
267
|
+
this.element.classList.remove("twm-managed-window--minimizing");
|
|
268
|
+
this._clearTargetProperties();
|
|
269
|
+
}
|
|
270
|
+
}, 200);
|
|
271
|
+
}
|
|
272
|
+
/** C27. Abandon a minimise animation that has not landed yet.
|
|
273
|
+
*
|
|
274
|
+
* The timer is dropped AND the class is removed, because the class is half
|
|
275
|
+
* the damage: `--minimizing` is `opacity: 0` plus a transform that parks
|
|
276
|
+
* the window over the taskbar plus `pointer-events: none`, so a window
|
|
277
|
+
* that keeps it is invisible and unclickable for the rest of the 200ms
|
|
278
|
+
* even before the timer hides it outright. */
|
|
279
|
+
_cancelMinimizeAnimation() {
|
|
280
|
+
if (this._minimizeTimer !== null) {
|
|
281
|
+
clearTimeout(this._minimizeTimer);
|
|
282
|
+
this._minimizeTimer = null;
|
|
283
|
+
}
|
|
284
|
+
this.element?.classList.remove("twm-managed-window--minimizing");
|
|
285
|
+
}
|
|
286
|
+
/** C27. Abandon a restore animation that has not landed yet. */
|
|
287
|
+
_cancelRestoreAnimation() {
|
|
288
|
+
if (this._restoreTimer !== null) {
|
|
289
|
+
clearTimeout(this._restoreTimer);
|
|
290
|
+
this._restoreTimer = null;
|
|
291
|
+
}
|
|
292
|
+
this.element?.classList.remove("twm-managed-window--restoring");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Restore from minimized state with animation.
|
|
296
|
+
*
|
|
297
|
+
* ══ C27. THE WINDOW THAT COULD NOT BE BROUGHT BACK ══════════════════
|
|
298
|
+
*
|
|
299
|
+
* `minimize` hides the element inside a `setTimeout(..., 200)` so the
|
|
300
|
+
* shrink-toward-the-taskbar animation has time to play, and this method
|
|
301
|
+
* cleared the `display` IMMEDIATELY. Minimise a window and restore it from
|
|
302
|
+
* the taskbar inside those 200ms — which is not a stress test, it is what
|
|
303
|
+
* "I clicked the wrong button" looks like — and the sequence ran:
|
|
304
|
+
*
|
|
305
|
+
* minimize() isMinimized = true, timer armed for +200ms
|
|
306
|
+
* _restore() display = '', isMinimized = FALSE, taskbar button gone
|
|
307
|
+
* +200ms the timer fires and writes `display: none`
|
|
308
|
+
*
|
|
309
|
+
* leaving a window that is off screen with `isMinimized === false`. Every
|
|
310
|
+
* route back is closed at once: `static restore` and `show` both funnel
|
|
311
|
+
* through the `isMinimized` guard above and return without doing anything,
|
|
312
|
+
* and a taskbar built from `ManagedWindow.all().filter(isMinimized)` —
|
|
313
|
+
* which is how `syncTaskbars` builds it — has no button for it either. The
|
|
314
|
+
* window is live, holding its content and its staged edits, and there is no
|
|
315
|
+
* gesture in the product that can reach it. Reported twice.
|
|
316
|
+
*
|
|
317
|
+
* The `--minimizing` CLASS is the same defect one layer up and it bites
|
|
318
|
+
* even before the timer does: it is `opacity: 0` with a transform parking
|
|
319
|
+
* the window over the taskbar and `pointer-events: none`, and it was left
|
|
320
|
+
* on for the remainder of the animation, so the restored window was
|
|
321
|
+
* invisible and unclickable for up to 200ms before disappearing outright.
|
|
322
|
+
*
|
|
323
|
+
* Both are cancelled here rather than worked around in a consumer. A
|
|
324
|
+
* consumer cannot see either one: nothing throws, no state is inconsistent
|
|
325
|
+
* at any moment a caller can observe, and the corruption is written by a
|
|
326
|
+
* timer with no name.
|
|
327
|
+
*/
|
|
328
|
+
_restore() {
|
|
329
|
+
if (!this.isMinimized) return;
|
|
330
|
+
const taskbarRect = this._getTaskbarItemRect();
|
|
331
|
+
this._cancelMinimizeAnimation();
|
|
332
|
+
if (this.element) {
|
|
333
|
+
this.element.style.display = "";
|
|
334
|
+
const animate = getSetting("window.animateMinimize", true);
|
|
335
|
+
if (animate) {
|
|
336
|
+
this._cancelRestoreAnimation();
|
|
337
|
+
this._setRestoreTargetProperties(taskbarRect);
|
|
338
|
+
this.element.classList.add("twm-managed-window--restoring");
|
|
339
|
+
this._restoreTimer = setTimeout(() => {
|
|
340
|
+
this._restoreTimer = null;
|
|
341
|
+
if (this.element) {
|
|
342
|
+
this.element.classList.remove("twm-managed-window--restoring");
|
|
343
|
+
this._clearTargetProperties();
|
|
344
|
+
}
|
|
345
|
+
}, 250);
|
|
346
|
+
} else {
|
|
347
|
+
this._clearTargetProperties();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (this.modal && this.backdropElement) {
|
|
351
|
+
this.backdropElement.style.display = "";
|
|
352
|
+
}
|
|
353
|
+
this.isMinimized = false;
|
|
354
|
+
this.bringToFront();
|
|
355
|
+
window.dispatchEvent(new CustomEvent("managed-window-restored", {
|
|
356
|
+
detail: { id: this.id, container: this.container }
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Toggle maximize state.
|
|
361
|
+
*/
|
|
362
|
+
/**
|
|
363
|
+
* @param {{claimable?: boolean}} [opts] `claimable: false` performs the
|
|
364
|
+
* GEOMETRIC maximise even when a consumer has claimed the gesture. NOTHING
|
|
365
|
+
* INSIDE THE LIBRARY PASSES IT — this docstring used to say the topbar's
|
|
366
|
+
* double-click did, and the binding at `:890` has never passed anything
|
|
367
|
+
* (C26 settled the other way; see the note there). It has had two callers
|
|
368
|
+
* outside it and it now has none: the window manager's aero-snap top edge
|
|
369
|
+
* used it for R13's maximise-onto-the-layer, and Tables' window menu drew
|
|
370
|
+
* a "Maximize" beside "Back to tile" and got its rectangle from here. R14
|
|
371
|
+
* collapsed both into the dock — maximise means back to tile, everywhere
|
|
372
|
+
* — so the escape hatch is now a LIBRARY API with no caller in this repo
|
|
373
|
+
* rather than a shared secret between two.
|
|
374
|
+
*
|
|
375
|
+
* IT IS KEPT, and deliberately. A window that came out of a tile has a
|
|
376
|
+
* tile to go back to; a window that never did has only the rectangle, and
|
|
377
|
+
* `openModal`'s dialogs are exactly that case (`modal.js`, `maximizable`)
|
|
378
|
+
* — they reach the same rectangle through the ordinary claimless path
|
|
379
|
+
* because they set no `onMaximize` at all. Removing this would leave a
|
|
380
|
+
* consumer that HAS claimed the gesture with no way to ask for the other
|
|
381
|
+
* verb, which is the situation the flag was added to fix.
|
|
382
|
+
*
|
|
383
|
+
* A doc that names a caller that does not exist is worse than no doc — it
|
|
384
|
+
* is the reason a reader concludes the double-click is already handled.
|
|
385
|
+
*/
|
|
386
|
+
toggleMaximize({ claimable = true } = {}) {
|
|
387
|
+
if (!this.canMaximize) return;
|
|
388
|
+
if (claimable && this.onMaximize) {
|
|
389
|
+
let handled = false;
|
|
390
|
+
try {
|
|
391
|
+
handled = this.onMaximize(this) ?? false;
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error("[managed-window] onMaximize threw", err);
|
|
394
|
+
}
|
|
395
|
+
if (handled) return;
|
|
396
|
+
}
|
|
397
|
+
if (this.isMaximized) {
|
|
398
|
+
if (this._preMaximizeState) {
|
|
399
|
+
this.x = this._preMaximizeState.x;
|
|
400
|
+
this.y = this._preMaximizeState.y;
|
|
401
|
+
this.width = this._preMaximizeState.width;
|
|
402
|
+
this.height = this._preMaximizeState.height;
|
|
403
|
+
this._preMaximizeState = null;
|
|
404
|
+
}
|
|
405
|
+
this.isMaximized = false;
|
|
406
|
+
} else {
|
|
407
|
+
this._preMaximizeState = { x: this.x, y: this.y, width: this.width, height: this.height };
|
|
408
|
+
const bounds = this._bounds();
|
|
409
|
+
this.x = bounds.minX;
|
|
410
|
+
this.y = bounds.minY;
|
|
411
|
+
this.width = bounds.width;
|
|
412
|
+
this.height = bounds.height;
|
|
413
|
+
this.isMaximized = true;
|
|
414
|
+
}
|
|
415
|
+
this.element?.classList.toggle("twm-managed-window--maximized", this.isMaximized);
|
|
416
|
+
this._applyPosition();
|
|
417
|
+
this._saveCurrentState();
|
|
418
|
+
window.dispatchEvent(new CustomEvent("managed-window-maximized", {
|
|
419
|
+
detail: { id: this.id, maximized: this.isMaximized, container: this.container }
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Find the taskbar button for this window.
|
|
424
|
+
* @returns {DOMRect|null}
|
|
425
|
+
*/
|
|
426
|
+
_getTaskbarItemRect() {
|
|
427
|
+
const btn = document.querySelector(`.twm-bar-windows__item[data-window-id="${this.id}"]`);
|
|
428
|
+
return btn ? btn.getBoundingClientRect() : null;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Set CSS custom properties to animate minimize toward the taskbar item.
|
|
432
|
+
*/
|
|
433
|
+
_setMinimizeTargetProperties() {
|
|
434
|
+
const targetRect = this._getTaskbarItemRect();
|
|
435
|
+
if (!targetRect || !this.element) return;
|
|
436
|
+
const winRect = this.element.getBoundingClientRect();
|
|
437
|
+
const winCenterX = winRect.left + winRect.width / 2;
|
|
438
|
+
const winCenterY = winRect.top + winRect.height / 2;
|
|
439
|
+
const targetCenterX = targetRect.left + targetRect.width / 2;
|
|
440
|
+
const targetCenterY = targetRect.top + targetRect.height / 2;
|
|
441
|
+
const dx = targetCenterX - winCenterX;
|
|
442
|
+
const dy = targetCenterY - winCenterY;
|
|
443
|
+
const scale = Math.min(targetRect.width / winRect.width, targetRect.height / winRect.height, 0.15);
|
|
444
|
+
this.element.style.setProperty("--mw-target-x", `${dx}px`);
|
|
445
|
+
this.element.style.setProperty("--mw-target-y", `${dy}px`);
|
|
446
|
+
this.element.style.setProperty("--mw-target-scale", scale);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Set CSS custom properties to animate restore from the taskbar item position.
|
|
450
|
+
* @param {DOMRect|null} taskbarRect
|
|
451
|
+
*/
|
|
452
|
+
_setRestoreTargetProperties(taskbarRect) {
|
|
453
|
+
if (!taskbarRect || !this.element) return;
|
|
454
|
+
const winRect = this.element.getBoundingClientRect();
|
|
455
|
+
const winCenterX = winRect.left + winRect.width / 2;
|
|
456
|
+
const winCenterY = winRect.top + winRect.height / 2;
|
|
457
|
+
const targetCenterX = taskbarRect.left + taskbarRect.width / 2;
|
|
458
|
+
const targetCenterY = taskbarRect.top + taskbarRect.height / 2;
|
|
459
|
+
const dx = targetCenterX - winCenterX;
|
|
460
|
+
const dy = targetCenterY - winCenterY;
|
|
461
|
+
const scale = Math.min(taskbarRect.width / winRect.width, taskbarRect.height / winRect.height, 0.15);
|
|
462
|
+
this.element.style.setProperty("--mw-target-x", `${dx}px`);
|
|
463
|
+
this.element.style.setProperty("--mw-target-y", `${dy}px`);
|
|
464
|
+
this.element.style.setProperty("--mw-target-scale", scale);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Clear animation CSS custom properties.
|
|
468
|
+
*/
|
|
469
|
+
_clearTargetProperties() {
|
|
470
|
+
if (!this.element) return;
|
|
471
|
+
this.element.style.removeProperty("--mw-target-x");
|
|
472
|
+
this.element.style.removeProperty("--mw-target-y");
|
|
473
|
+
this.element.style.removeProperty("--mw-target-scale");
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Bring window to front of z-order.
|
|
477
|
+
*/
|
|
478
|
+
bringToFront() {
|
|
479
|
+
_zIndexCounter++;
|
|
480
|
+
this.zIndex = Math.min(BASE_Z_INDEX + _zIndexCounter, MAX_Z_INDEX);
|
|
481
|
+
if (this.element) {
|
|
482
|
+
this.element.style.zIndex = this.zIndex.toString();
|
|
483
|
+
}
|
|
484
|
+
if (this.backdropElement) {
|
|
485
|
+
this.backdropElement.style.zIndex = (this.zIndex - 1).toString();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Update window title.
|
|
490
|
+
*/
|
|
491
|
+
setTitle(title) {
|
|
492
|
+
this.title = title;
|
|
493
|
+
if (this.element) {
|
|
494
|
+
const titleEl = this.element.querySelector(".twm-managed-window__title");
|
|
495
|
+
if (titleEl) titleEl.textContent = title;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// ========== Private Methods ==========
|
|
499
|
+
_build() {
|
|
500
|
+
if (this.modal) {
|
|
501
|
+
this.backdropElement = document.createElement("div");
|
|
502
|
+
this.backdropElement.className = "twm-managed-window__backdrop";
|
|
503
|
+
if (this.backdropBlur !== void 0 && this.backdropBlur !== null) {
|
|
504
|
+
this.backdropElement.style.backdropFilter = this.backdropBlur > 0 ? `blur(${this.backdropBlur}px)` : "none";
|
|
505
|
+
}
|
|
506
|
+
if (this.backdropOpacity !== void 0 && this.backdropOpacity !== null) {
|
|
507
|
+
this.backdropElement.style.background = `rgba(0, 0, 0, ${this.backdropOpacity})`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
this.element = document.createElement("div");
|
|
511
|
+
this.element.className = "twm-managed-window";
|
|
512
|
+
if (!this.canDrag) {
|
|
513
|
+
this.element.classList.add("twm-managed-window--no-drag");
|
|
514
|
+
}
|
|
515
|
+
if (this.modal) {
|
|
516
|
+
this.element.classList.add("twm-managed-window--modal");
|
|
517
|
+
}
|
|
518
|
+
this.element.setAttribute("data-window-id", this.id);
|
|
519
|
+
const topbar = document.createElement("div");
|
|
520
|
+
topbar.className = "twm-managed-window__topbar";
|
|
521
|
+
const icon = document.createElement("span");
|
|
522
|
+
icon.className = "twm-managed-window__icon material-symbols-outlined";
|
|
523
|
+
icon.textContent = this.icon;
|
|
524
|
+
const title = document.createElement("div");
|
|
525
|
+
title.className = "twm-managed-window__title";
|
|
526
|
+
title.textContent = this.title;
|
|
527
|
+
const buttons = document.createElement("div");
|
|
528
|
+
buttons.className = "twm-managed-window__buttons";
|
|
529
|
+
for (const spec of this.titlebarButtons) {
|
|
530
|
+
const btn = document.createElement("button");
|
|
531
|
+
btn.className = "twm-managed-window__btn twm-managed-window__btn--custom";
|
|
532
|
+
btn.type = "button";
|
|
533
|
+
btn.title = spec.title || "";
|
|
534
|
+
if (spec.icon) {
|
|
535
|
+
const glyph = document.createElement("span");
|
|
536
|
+
glyph.className = "material-symbols-outlined";
|
|
537
|
+
glyph.textContent = spec.icon;
|
|
538
|
+
btn.appendChild(glyph);
|
|
539
|
+
} else {
|
|
540
|
+
btn.textContent = spec.label || "";
|
|
541
|
+
}
|
|
542
|
+
btn.addEventListener("click", (e) => {
|
|
543
|
+
e.stopPropagation();
|
|
544
|
+
spec.onClick?.(this, e);
|
|
545
|
+
});
|
|
546
|
+
buttons.appendChild(btn);
|
|
547
|
+
}
|
|
548
|
+
if (this.canMinimize) {
|
|
549
|
+
const minBtn = document.createElement("button");
|
|
550
|
+
minBtn.className = "twm-managed-window__btn twm-managed-window__btn--minimize managed-window__btn--minimize";
|
|
551
|
+
minBtn.type = "button";
|
|
552
|
+
minBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1 5h8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
553
|
+
minBtn.title = "Minimize";
|
|
554
|
+
minBtn.addEventListener("click", (e) => {
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
this.minimize();
|
|
557
|
+
});
|
|
558
|
+
buttons.appendChild(minBtn);
|
|
559
|
+
}
|
|
560
|
+
if (this.canMaximize) {
|
|
561
|
+
const maxBtn = document.createElement("button");
|
|
562
|
+
maxBtn.className = "twm-managed-window__btn twm-managed-window__btn--maximize managed-window__btn--maximize";
|
|
563
|
+
maxBtn.type = "button";
|
|
564
|
+
if (this.maximizeIcon) {
|
|
565
|
+
const glyph = document.createElement("span");
|
|
566
|
+
glyph.className = "material-symbols-outlined";
|
|
567
|
+
glyph.textContent = this.maximizeIcon;
|
|
568
|
+
maxBtn.appendChild(glyph);
|
|
569
|
+
} else {
|
|
570
|
+
maxBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
571
|
+
}
|
|
572
|
+
maxBtn.title = this.maximizeTitle;
|
|
573
|
+
maxBtn.addEventListener("click", (e) => {
|
|
574
|
+
e.stopPropagation();
|
|
575
|
+
this.toggleMaximize();
|
|
576
|
+
});
|
|
577
|
+
buttons.appendChild(maxBtn);
|
|
578
|
+
}
|
|
579
|
+
const closeBtn = document.createElement("button");
|
|
580
|
+
closeBtn.className = "twm-managed-window__btn twm-managed-window__btn--close";
|
|
581
|
+
closeBtn.type = "button";
|
|
582
|
+
closeBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
|
|
583
|
+
closeBtn.title = "Close";
|
|
584
|
+
closeBtn.addEventListener("click", (e) => {
|
|
585
|
+
e.stopPropagation();
|
|
586
|
+
this.close();
|
|
587
|
+
});
|
|
588
|
+
buttons.appendChild(closeBtn);
|
|
589
|
+
topbar.appendChild(icon);
|
|
590
|
+
topbar.appendChild(title);
|
|
591
|
+
topbar.appendChild(buttons);
|
|
592
|
+
this.contentContainer = document.createElement("div");
|
|
593
|
+
this.contentContainer.className = "twm-managed-window__content";
|
|
594
|
+
if (this.content instanceof HTMLElement) {
|
|
595
|
+
this.contentContainer.appendChild(this.content);
|
|
596
|
+
} else if (typeof this.content === "function") {
|
|
597
|
+
const rendered = this.content();
|
|
598
|
+
if (rendered instanceof HTMLElement) {
|
|
599
|
+
this.contentContainer.appendChild(rendered);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
this.element.appendChild(topbar);
|
|
603
|
+
this.element.appendChild(this.contentContainer);
|
|
604
|
+
if (this.canResize) {
|
|
605
|
+
this._addResizeHandles();
|
|
606
|
+
}
|
|
607
|
+
topbar.addEventListener("pointerdown", (e) => this._onTopbarPointerDown(e));
|
|
608
|
+
if (this.canMaximize) {
|
|
609
|
+
topbar.addEventListener("dblclick", () => this.toggleMaximize());
|
|
610
|
+
}
|
|
611
|
+
this.element.addEventListener("pointerdown", () => this.bringToFront());
|
|
612
|
+
}
|
|
613
|
+
_addResizeHandles() {
|
|
614
|
+
const directions = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
|
|
615
|
+
for (const dir of directions) {
|
|
616
|
+
const handle = document.createElement("div");
|
|
617
|
+
handle.className = `twm-managed-window__resize twm-managed-window__resize--${dir} managed-window__resize--${dir}`;
|
|
618
|
+
handle.addEventListener("pointerdown", (e) => this._onResizePointerDown(e, dir));
|
|
619
|
+
this.element.appendChild(handle);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** C2. The bounds rectangle this window is clamped inside.
|
|
623
|
+
*
|
|
624
|
+
* ONE computation replacing four inline copies. The `document.body` case —
|
|
625
|
+
* every call site that exists today — reduces to the previous expression
|
|
626
|
+
* BY SUBSTITUTION, not by "should be equivalent":
|
|
627
|
+
*
|
|
628
|
+
* minX = 0
|
|
629
|
+
* minY = TOP_BAR_HEIGHT
|
|
630
|
+
* width = window.innerWidth
|
|
631
|
+
* height = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT
|
|
632
|
+
*
|
|
633
|
+
* so, substituting into the clamps below:
|
|
634
|
+
*
|
|
635
|
+
* maxWidth = width = window.innerWidth ✓
|
|
636
|
+
* maxHeight = height = innerHeight - TOP - BOTTOM ✓
|
|
637
|
+
* maxX = max(minX, minX + width - w) = max(0, innerWidth - w) ✓
|
|
638
|
+
* maxY = max(minY, minY + height - h) = max(TOP, innerHeight - BOTTOM - h) ✓
|
|
639
|
+
* x = max(minX, min(x, maxX)) = max(0, min(x, maxX)) ✓
|
|
640
|
+
* y = max(minY, min(y, maxY)) = max(TOP, min(y, maxY)) ✓
|
|
641
|
+
*
|
|
642
|
+
* EcoAgent's and EcoSim's modals depend on that arithmetic; prove the
|
|
643
|
+
* equivalence in review by substitution rather than by testing.
|
|
644
|
+
*/
|
|
645
|
+
_bounds() {
|
|
646
|
+
if (this.dragBounds) {
|
|
647
|
+
const host = typeof this.dragHost === "function" ? this.dragHost(this) : this.dragHost;
|
|
648
|
+
if (this._escapeOrigin || host && host === this.container) {
|
|
649
|
+
const b = this.dragBounds(this);
|
|
650
|
+
if (b && b.width > 0 && b.height > 0) return b;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (this.container) {
|
|
654
|
+
return {
|
|
655
|
+
minX: 0,
|
|
656
|
+
minY: 0,
|
|
657
|
+
width: this.container.clientWidth,
|
|
658
|
+
height: this.container.clientHeight
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
minX: 0,
|
|
663
|
+
minY: TOP_BAR_HEIGHT,
|
|
664
|
+
width: window.innerWidth,
|
|
665
|
+
height: window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
_applyPosition() {
|
|
669
|
+
if (!this.element) return;
|
|
670
|
+
const bounds = this._bounds();
|
|
671
|
+
const maxWidth = bounds.width;
|
|
672
|
+
const maxHeight = bounds.height;
|
|
673
|
+
this.width = Math.max(this.minWidth, Math.min(this.width, maxWidth));
|
|
674
|
+
this.height = Math.max(this.minHeight, Math.min(this.height, maxHeight));
|
|
675
|
+
const maxX = Math.max(bounds.minX, bounds.minX + maxWidth - this.width);
|
|
676
|
+
const maxY = Math.max(bounds.minY, bounds.minY + maxHeight - this.height);
|
|
677
|
+
this.x = Math.max(bounds.minX, Math.min(this.x, maxX));
|
|
678
|
+
this.y = Math.max(bounds.minY, Math.min(this.y, maxY));
|
|
679
|
+
this.element.style.left = `${this.x}px`;
|
|
680
|
+
this.element.style.top = `${this.y}px`;
|
|
681
|
+
this.element.style.width = `${this.width}px`;
|
|
682
|
+
this.element.style.height = `${this.height}px`;
|
|
683
|
+
}
|
|
684
|
+
/** C12. Re-clamp on demand.
|
|
685
|
+
*
|
|
686
|
+
* The ResizeObserver below covers a container that changes size on its
|
|
687
|
+
* own. A container that changes size because a SIBLING did — a splitter
|
|
688
|
+
* drag moves two panels at once — needs the caller to say so, and a
|
|
689
|
+
* private `_applyPosition` is not something a caller may reach for. */
|
|
690
|
+
reclamp() {
|
|
691
|
+
this._applyPosition();
|
|
692
|
+
}
|
|
693
|
+
/** C12. Move this window into a different container.
|
|
694
|
+
*
|
|
695
|
+
* Re-parents the element and re-clamps against the new bounds, because a
|
|
696
|
+
* window carried into a narrower region would otherwise keep coordinates
|
|
697
|
+
* that put it outside and out of reach. The ResizeObserver follows the new
|
|
698
|
+
* container, or the old one would keep driving the clamp.
|
|
699
|
+
*/
|
|
700
|
+
moveTo(container) {
|
|
701
|
+
if (!container || container === this.container) return false;
|
|
702
|
+
this.container = container;
|
|
703
|
+
if (this.element) {
|
|
704
|
+
container.appendChild(this.element);
|
|
705
|
+
this.element.classList.toggle("twm-managed-window--contained", true);
|
|
706
|
+
if (this.backdropElement) container.appendChild(this.backdropElement);
|
|
707
|
+
}
|
|
708
|
+
this._resizeObserver?.disconnect();
|
|
709
|
+
this._resizeObserver = null;
|
|
710
|
+
this._installContainerResizeObserver();
|
|
711
|
+
this._preSnapState = null;
|
|
712
|
+
this.element?.classList.remove("twm-managed-window--snapped");
|
|
713
|
+
if (this.isMaximized) {
|
|
714
|
+
const bounds = this._bounds();
|
|
715
|
+
this.x = bounds.minX;
|
|
716
|
+
this.y = bounds.minY;
|
|
717
|
+
this.width = bounds.width;
|
|
718
|
+
this.height = bounds.height;
|
|
719
|
+
}
|
|
720
|
+
this._applyPosition();
|
|
721
|
+
window.dispatchEvent(new CustomEvent("managed-window-moved", {
|
|
722
|
+
detail: { id: this.id, container }
|
|
723
|
+
}));
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
/** R1/R3. The container this drag started in, or null when the drag did
|
|
727
|
+
* not have to escape one (an uncontained window, or no `dragHost`).
|
|
728
|
+
*
|
|
729
|
+
* Public because the decision that needs it is not this component's: a snap
|
|
730
|
+
* controller has to know which pane is HOME so that moving a window around
|
|
731
|
+
* inside the pane it already lives in arms nothing. That was the complaint
|
|
732
|
+
* about the old behaviour — in-pane, virtually any movement was a dock. */
|
|
733
|
+
get dragOrigin() {
|
|
734
|
+
return this._escapeOrigin;
|
|
735
|
+
}
|
|
736
|
+
/** R1. Take the window out of its container for the duration of a drag.
|
|
737
|
+
*
|
|
738
|
+
* A contained window is clipped by its container (`.twm-leaf` is
|
|
739
|
+
* `overflow: hidden`), so without this it cannot be dragged one pixel past
|
|
740
|
+
* the pane it lives in — the gesture the tiling model is built on is not
|
|
741
|
+
* merely awkward, it is invisible. The window is re-parented into the
|
|
742
|
+
* wider `dragHost` and its coordinates are converted so the rectangle on
|
|
743
|
+
* screen does not move: same viewport pixels, different reference frame.
|
|
744
|
+
*
|
|
745
|
+
* Deliberately NOT `moveTo` (C12), which is the same re-parent for a
|
|
746
|
+
* different purpose. `moveTo` re-CLAMPS into the new container without
|
|
747
|
+
* converting anything, which is right when a window is carried between
|
|
748
|
+
* regions by a menu and wrong here — the window would jump out from under
|
|
749
|
+
* the pointer at the first millimetre of every drag. It also drops the snap
|
|
750
|
+
* and announces `managed-window-moved`, and an escape is neither a move the
|
|
751
|
+
* user asked for nor one anybody should hear about: it is undone on
|
|
752
|
+
* release, either by `_endDragEscape` or by the drop taking the window.
|
|
753
|
+
*/
|
|
754
|
+
_beginDragEscape() {
|
|
755
|
+
if (this._escapeOrigin || !this.container || !this.dragHost) return false;
|
|
756
|
+
const host = typeof this.dragHost === "function" ? this.dragHost(this) : this.dragHost;
|
|
757
|
+
if (!host || host === this.container || !host.contains(this.container)) return false;
|
|
758
|
+
const origin = this.container;
|
|
759
|
+
this._escapeOrigin = origin;
|
|
760
|
+
this._reparentPreservingPosition(host);
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
/** R1. Put the window back into a container when the drag ends.
|
|
764
|
+
*
|
|
765
|
+
* `taken` means the drop was claimed by the snap controller: the window is
|
|
766
|
+
* being docked into a tree and closed, so re-parenting it into a pane it is
|
|
767
|
+
* about to leave would be work done for a frame nobody sees.
|
|
768
|
+
*
|
|
769
|
+
* Otherwise it goes back where the drag started — including when the drag
|
|
770
|
+
* ended over nothing (the rail, the gap between two panes, off the edge).
|
|
771
|
+
* A window that lives in a pane has to end every drag in SOME pane, and the
|
|
772
|
+
* one it came from is the only answer that never surprises anyone. The one
|
|
773
|
+
* case that cannot be honoured is an origin that stopped being in the
|
|
774
|
+
* document mid-drag — a repaint rebuilt the leaf wrap — and there the
|
|
775
|
+
* window stays on the host rather than being orphaned into a detached
|
|
776
|
+
* node; the WM's own re-home pass adopts it on the next render.
|
|
777
|
+
*/
|
|
778
|
+
_endDragEscape({ taken = false } = {}) {
|
|
779
|
+
const origin = this._escapeOrigin;
|
|
780
|
+
this._escapeOrigin = null;
|
|
781
|
+
this.element?.classList.remove("twm-managed-window--detached");
|
|
782
|
+
if (!origin || taken) return false;
|
|
783
|
+
if (!origin.isConnected) return false;
|
|
784
|
+
this._reparentPreservingPosition(origin);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
/** Move the element into `next` and rewrite `x`/`y` so it occupies the same
|
|
788
|
+
* viewport rectangle it did a moment ago.
|
|
789
|
+
*
|
|
790
|
+
* `x`/`y` are written against the containing block, which for an absolutely
|
|
791
|
+
* positioned child is the PADDING box — hence `clientLeft`/`clientTop`,
|
|
792
|
+
* which are the border widths `getBoundingClientRect` includes and the
|
|
793
|
+
* offset does not. `scrollLeft`/`scrollTop` are zero for every box either
|
|
794
|
+
* side of this today (panes and the WM root both clip rather than scroll)
|
|
795
|
+
* and are in the expression anyway: the day one of them scrolls, this is
|
|
796
|
+
* the line that would be silently half a screen out.
|
|
797
|
+
*
|
|
798
|
+
* The container's ResizeObserver is deliberately NOT re-pointed. It exists
|
|
799
|
+
* to re-clamp (C4), it re-clamps against whichever container is current
|
|
800
|
+
* because `_applyPosition` reads `_bounds()` afresh, and an escape is
|
|
801
|
+
* transient by construction — undone on release, or ended by the window
|
|
802
|
+
* closing into a tree. `moveTo`, which is a permanent move, does re-point
|
|
803
|
+
* it, and that difference is the reason these are two methods.
|
|
804
|
+
*/
|
|
805
|
+
_reparentPreservingPosition(next) {
|
|
806
|
+
const prev = this.container;
|
|
807
|
+
if (!next || next === prev) return false;
|
|
808
|
+
if (this.element && prev) {
|
|
809
|
+
const from = prev.getBoundingClientRect();
|
|
810
|
+
const to = next.getBoundingClientRect();
|
|
811
|
+
this.x += from.left + prev.clientLeft - prev.scrollLeft - (to.left + next.clientLeft - next.scrollLeft);
|
|
812
|
+
this.y += from.top + prev.clientTop - prev.scrollTop - (to.top + next.clientTop - next.scrollTop);
|
|
813
|
+
}
|
|
814
|
+
this.container = next;
|
|
815
|
+
if (this.element) {
|
|
816
|
+
next.appendChild(this.element);
|
|
817
|
+
this.element.classList.toggle("twm-managed-window--contained", !!next);
|
|
818
|
+
if (this.backdropElement) next.appendChild(this.backdropElement);
|
|
819
|
+
}
|
|
820
|
+
this._applyPosition();
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
/** R3. HALF TRANSPARENT THE MOMENT IT LEAVES THE TILE IT CAME FROM.
|
|
824
|
+
*
|
|
825
|
+
* The signal that releasing now will dock the window somewhere, and the
|
|
826
|
+
* complement of the rule that keeps the origin pane silent: inside it, this
|
|
827
|
+
* is just a window being moved and it stays opaque. Only for a window with
|
|
828
|
+
* a snap controller — nothing else on the page can be docked, so nothing
|
|
829
|
+
* else has anything to promise.
|
|
830
|
+
*
|
|
831
|
+
* A window that never had a pane (opened with Alt+N, floating over the
|
|
832
|
+
* whole root) has no "inside" to be in, so it reads as detached for the
|
|
833
|
+
* whole drag. That is not a special case being papered over: every pane
|
|
834
|
+
* under it genuinely is foreign, and every drop on one genuinely docks.
|
|
835
|
+
*/
|
|
836
|
+
_syncDragTransparency(e) {
|
|
837
|
+
if (!this.snapController || !this.element) return;
|
|
838
|
+
const origin = this._escapeOrigin;
|
|
839
|
+
let outside = true;
|
|
840
|
+
if (origin && origin.isConnected) {
|
|
841
|
+
const r = origin.getBoundingClientRect();
|
|
842
|
+
outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
|
|
843
|
+
}
|
|
844
|
+
this.element.classList.toggle("twm-managed-window--detached", outside);
|
|
845
|
+
}
|
|
846
|
+
/** C4. Re-clamp when the container resizes.
|
|
847
|
+
*
|
|
848
|
+
* `managed_window.js` has NO resize listener at all today: a viewport
|
|
849
|
+
* resize simply leaves windows where they were until the next pointer
|
|
850
|
+
* move re-clamps them. That is survivable for the viewport, which resizes
|
|
851
|
+
* rarely, and not for a panel, which resizes every time someone drags a
|
|
852
|
+
* splitter — a window would end up outside its own panel and unreachable.
|
|
853
|
+
*/
|
|
854
|
+
_installContainerResizeObserver() {
|
|
855
|
+
if (!this.container || this._resizeObserver) return;
|
|
856
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
857
|
+
if (this.isMaximized) {
|
|
858
|
+
const bounds = this._bounds();
|
|
859
|
+
this.x = bounds.minX;
|
|
860
|
+
this.y = bounds.minY;
|
|
861
|
+
this.width = bounds.width;
|
|
862
|
+
this.height = bounds.height;
|
|
863
|
+
}
|
|
864
|
+
this._applyPosition();
|
|
865
|
+
});
|
|
866
|
+
this._resizeObserver.observe(this.container);
|
|
867
|
+
}
|
|
868
|
+
_teardownContainerResizeObserver() {
|
|
869
|
+
this._resizeObserver?.disconnect();
|
|
870
|
+
this._resizeObserver = null;
|
|
871
|
+
}
|
|
872
|
+
_restoreState() {
|
|
873
|
+
const bounds = this._bounds();
|
|
874
|
+
const maxWidth = bounds.width;
|
|
875
|
+
const maxHeight = bounds.height;
|
|
876
|
+
const saved = this.modal ? null : _getWindowState(this.id);
|
|
877
|
+
if (saved) {
|
|
878
|
+
this.x = saved.x ?? this.x;
|
|
879
|
+
this.y = saved.y ?? this.y;
|
|
880
|
+
this.width = Math.min(saved.width ?? this.width, maxWidth);
|
|
881
|
+
this.height = Math.min(saved.height ?? this.height, maxHeight);
|
|
882
|
+
this.isMaximized = saved.maximized ?? false;
|
|
883
|
+
if (this.isMaximized && this.canMaximize) {
|
|
884
|
+
this._preMaximizeState = { x: saved.x, y: saved.y, width: saved.width, height: saved.height };
|
|
885
|
+
this.x = bounds.minX;
|
|
886
|
+
this.y = bounds.minY;
|
|
887
|
+
this.width = maxWidth;
|
|
888
|
+
this.height = maxHeight;
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
this.x = Math.max(0, (maxWidth - this.width) / 2);
|
|
892
|
+
this.y = Math.max(TOP_BAR_HEIGHT, TOP_BAR_HEIGHT + (maxHeight - this.height) / 2);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
_saveCurrentState() {
|
|
896
|
+
if (this.modal) return;
|
|
897
|
+
_setWindowState(this.id, {
|
|
898
|
+
x: this._preMaximizeState?.x ?? this.x,
|
|
899
|
+
y: this._preMaximizeState?.y ?? this.y,
|
|
900
|
+
width: this._preMaximizeState?.width ?? this.width,
|
|
901
|
+
height: this._preMaximizeState?.height ?? this.height,
|
|
902
|
+
maximized: this.isMaximized
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
// ========== Drag Handling ==========
|
|
906
|
+
_onTopbarPointerDown(e) {
|
|
907
|
+
if (e.target.closest(".twm-managed-window__buttons")) return;
|
|
908
|
+
if (this.isMaximized) return;
|
|
909
|
+
if (!this.canDrag) return;
|
|
910
|
+
e.preventDefault();
|
|
911
|
+
if (this.snap && this._preSnapState) {
|
|
912
|
+
const grabRatio = this.width ? (e.clientX - this.x) / this.width : 0.5;
|
|
913
|
+
this.unsnap();
|
|
914
|
+
this.x = Math.round(e.clientX - this.width * grabRatio);
|
|
915
|
+
this._applyPosition();
|
|
916
|
+
}
|
|
917
|
+
this._dragState = {
|
|
918
|
+
startX: e.clientX,
|
|
919
|
+
startY: e.clientY,
|
|
920
|
+
startWinX: this.x,
|
|
921
|
+
startWinY: this.y
|
|
922
|
+
};
|
|
923
|
+
document.addEventListener("pointermove", this._boundOnPointerMove);
|
|
924
|
+
document.addEventListener("pointerup", this._boundOnPointerUp);
|
|
925
|
+
document.addEventListener("pointercancel", this._boundOnPointerUp);
|
|
926
|
+
}
|
|
927
|
+
_onPointerMove(e) {
|
|
928
|
+
if (this._dragState) {
|
|
929
|
+
if (!this._escapeOrigin) {
|
|
930
|
+
const fromX = this.x;
|
|
931
|
+
const fromY = this.y;
|
|
932
|
+
if (this._beginDragEscape()) {
|
|
933
|
+
this._dragState.startWinX += this.x - fromX;
|
|
934
|
+
this._dragState.startWinY += this.y - fromY;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const dx = e.clientX - this._dragState.startX;
|
|
938
|
+
const dy = e.clientY - this._dragState.startY;
|
|
939
|
+
this.x = this._dragState.startWinX + dx;
|
|
940
|
+
this.y = this._dragState.startWinY + dy;
|
|
941
|
+
this._applyPosition();
|
|
942
|
+
this._syncDragTransparency(e);
|
|
943
|
+
if (this.snap) this._updateSnapZone(e);
|
|
944
|
+
} else if (this._resizeState) {
|
|
945
|
+
this._handleResize(e);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
_onPointerUp() {
|
|
949
|
+
let taken = false;
|
|
950
|
+
if (this._dragState && this.snap && this._snapZone) {
|
|
951
|
+
if (this.snapController) {
|
|
952
|
+
try {
|
|
953
|
+
taken = this.snapController.commit?.(this._snapProbe, this) ?? false;
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.error("[managed-window] snap commit threw", err);
|
|
956
|
+
taken = false;
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
this._applySnap(this._snapZone);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (!taken) this.clearSnapPreview();
|
|
963
|
+
this._snapZone = null;
|
|
964
|
+
if (this._dragState) this._endDragEscape({ taken });
|
|
965
|
+
if (this._dragState || this._resizeState) {
|
|
966
|
+
this._saveCurrentState();
|
|
967
|
+
}
|
|
968
|
+
this._dragState = null;
|
|
969
|
+
this._resizeState = null;
|
|
970
|
+
document.removeEventListener("pointermove", this._boundOnPointerMove);
|
|
971
|
+
document.removeEventListener("pointerup", this._boundOnPointerUp);
|
|
972
|
+
document.removeEventListener("pointercancel", this._boundOnPointerUp);
|
|
973
|
+
}
|
|
974
|
+
// ========== C11. Aero Snap ==========
|
|
975
|
+
/** The rectangle a zone would give this window, in the SAME coordinate
|
|
976
|
+
* space `_applyPosition` writes — container-relative when contained,
|
|
977
|
+
* viewport-relative otherwise. One source for the preview and the apply,
|
|
978
|
+
* so the preview cannot promise a rectangle the drop does not deliver. */
|
|
979
|
+
_snapRect(zone) {
|
|
980
|
+
const b = this._bounds();
|
|
981
|
+
const half = Math.round(b.width / 2);
|
|
982
|
+
switch (zone) {
|
|
983
|
+
case "top":
|
|
984
|
+
return { x: b.minX, y: b.minY, width: b.width, height: b.height };
|
|
985
|
+
case "left":
|
|
986
|
+
return { x: b.minX, y: b.minY, width: half, height: b.height };
|
|
987
|
+
case "right":
|
|
988
|
+
return {
|
|
989
|
+
x: b.minX + b.width - half,
|
|
990
|
+
y: b.minY,
|
|
991
|
+
width: half,
|
|
992
|
+
height: b.height
|
|
993
|
+
};
|
|
994
|
+
default:
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/** Which zone the POINTER is in — not the window. Using the window's own
|
|
999
|
+
* edge would make a wide window snap the moment it is picked up, because
|
|
1000
|
+
* it is already touching the edge it did not move towards. */
|
|
1001
|
+
_zoneFor(e) {
|
|
1002
|
+
const host = this.container || document.documentElement;
|
|
1003
|
+
const rect = this.container ? host.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
|
1004
|
+
const x = e.clientX - rect.left;
|
|
1005
|
+
const y = e.clientY - rect.top;
|
|
1006
|
+
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
|
|
1007
|
+
if (y <= SNAP_EDGE) return "top";
|
|
1008
|
+
if (x <= SNAP_EDGE) return "left";
|
|
1009
|
+
if (x >= rect.width - SNAP_EDGE) return "right";
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
_updateSnapZone(e) {
|
|
1013
|
+
if (this.snapController) {
|
|
1014
|
+
this._updateControlledSnapZone(e);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const zone = this._zoneFor(e);
|
|
1018
|
+
if (zone === this._snapZone) return;
|
|
1019
|
+
this._snapZone = zone;
|
|
1020
|
+
if (!zone) {
|
|
1021
|
+
this.clearSnapPreview();
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
this.showSnapPreview(this._snapRect(zone));
|
|
1025
|
+
}
|
|
1026
|
+
/** C15. The controller's half of `_updateSnapZone`. The probe runs on every
|
|
1027
|
+
* move because the rectangle can change while the KEY does not — a tile
|
|
1028
|
+
* resized underneath the pointer, a menu re-previewing the same zone — but
|
|
1029
|
+
* the DOM is only touched when something actually differs. */
|
|
1030
|
+
_updateControlledSnapZone(e) {
|
|
1031
|
+
let probe = null;
|
|
1032
|
+
try {
|
|
1033
|
+
probe = this.snapController.probe?.(e, this) ?? null;
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
console.warn("[managed-window] snap probe threw", err);
|
|
1036
|
+
}
|
|
1037
|
+
this._snapProbe = probe;
|
|
1038
|
+
this._snapZone = probe?.key ?? null;
|
|
1039
|
+
if (!probe?.rect) {
|
|
1040
|
+
this.clearSnapPreview();
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
this.showSnapPreview(probe.rect, { viewport: true });
|
|
1044
|
+
}
|
|
1045
|
+
/** Paint the drag affordance. `rect` is container-relative by default —
|
|
1046
|
+
* the same space `_applyPosition` writes — and viewport-relative for a
|
|
1047
|
+
* controller, whose rectangles come from hit-testing other people's DOM.
|
|
1048
|
+
* Public because a controller that survives the pointer-up owns it. */
|
|
1049
|
+
showSnapPreview(rect, { viewport = false } = {}) {
|
|
1050
|
+
if (!rect) {
|
|
1051
|
+
this.clearSnapPreview();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const host = viewport ? document.body : this.container || document.body;
|
|
1055
|
+
if (!this._snapPreviewEl) {
|
|
1056
|
+
this._snapPreviewEl = document.createElement("div");
|
|
1057
|
+
this._snapPreviewEl.setAttribute("aria-hidden", "true");
|
|
1058
|
+
}
|
|
1059
|
+
this._snapPreviewEl.className = `twm-snap-preview${viewport ? " twm-snap-preview--viewport" : ""}`;
|
|
1060
|
+
const left = rect.left ?? rect.x;
|
|
1061
|
+
const top = rect.top ?? rect.y;
|
|
1062
|
+
Object.assign(this._snapPreviewEl.style, {
|
|
1063
|
+
left: `${left}px`,
|
|
1064
|
+
top: `${top}px`,
|
|
1065
|
+
width: `${rect.width}px`,
|
|
1066
|
+
height: `${rect.height}px`
|
|
1067
|
+
});
|
|
1068
|
+
if (this._snapPreviewEl.parentNode !== host) host.appendChild(this._snapPreviewEl);
|
|
1069
|
+
}
|
|
1070
|
+
clearSnapPreview() {
|
|
1071
|
+
this._snapPreviewEl?.remove();
|
|
1072
|
+
this._snapProbe = null;
|
|
1073
|
+
}
|
|
1074
|
+
/** @deprecated retained so nothing inside this file has to change spelling
|
|
1075
|
+
* in the same commit that adds the public one. */
|
|
1076
|
+
_clearSnapPreview() {
|
|
1077
|
+
this.clearSnapPreview();
|
|
1078
|
+
}
|
|
1079
|
+
/** Applied on release. The pre-snap geometry is remembered so dragging the
|
|
1080
|
+
* window off an edge restores the size it had — a snap that eats the
|
|
1081
|
+
* original size makes the gesture one-way and people stop using it. */
|
|
1082
|
+
_applySnap(zone) {
|
|
1083
|
+
const rect = this._snapRect(zone);
|
|
1084
|
+
if (!rect) return;
|
|
1085
|
+
if (!this._preSnapState) {
|
|
1086
|
+
this._preSnapState = {
|
|
1087
|
+
x: this._dragState.startWinX,
|
|
1088
|
+
y: this._dragState.startWinY,
|
|
1089
|
+
width: this.width,
|
|
1090
|
+
height: this.height
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
this.x = rect.x;
|
|
1094
|
+
this.y = rect.y;
|
|
1095
|
+
this.width = rect.width;
|
|
1096
|
+
this.height = rect.height;
|
|
1097
|
+
this._applyPosition();
|
|
1098
|
+
this.element?.classList.add("twm-managed-window--snapped");
|
|
1099
|
+
window.dispatchEvent(new CustomEvent("managed-window-snapped", {
|
|
1100
|
+
detail: { id: this.id, zone, container: this.container }
|
|
1101
|
+
}));
|
|
1102
|
+
}
|
|
1103
|
+
/** Restore the geometry a snap replaced. Called when a snapped window is
|
|
1104
|
+
* picked up again, which is the gesture that means "un-snap". */
|
|
1105
|
+
unsnap() {
|
|
1106
|
+
if (!this._preSnapState) return false;
|
|
1107
|
+
const { x, y, width, height } = this._preSnapState;
|
|
1108
|
+
this._preSnapState = null;
|
|
1109
|
+
this.x = x;
|
|
1110
|
+
this.y = y;
|
|
1111
|
+
this.width = width;
|
|
1112
|
+
this.height = height;
|
|
1113
|
+
this._applyPosition();
|
|
1114
|
+
this.element?.classList.remove("twm-managed-window--snapped");
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
// ========== Resize Handling ==========
|
|
1118
|
+
_onResizePointerDown(e, direction) {
|
|
1119
|
+
if (this.isMaximized) return;
|
|
1120
|
+
e.preventDefault();
|
|
1121
|
+
e.stopPropagation();
|
|
1122
|
+
this._resizeState = {
|
|
1123
|
+
direction,
|
|
1124
|
+
startX: e.clientX,
|
|
1125
|
+
startY: e.clientY,
|
|
1126
|
+
startWinX: this.x,
|
|
1127
|
+
startWinY: this.y,
|
|
1128
|
+
startWidth: this.width,
|
|
1129
|
+
startHeight: this.height
|
|
1130
|
+
};
|
|
1131
|
+
document.addEventListener("pointermove", this._boundOnPointerMove);
|
|
1132
|
+
document.addEventListener("pointerup", this._boundOnPointerUp);
|
|
1133
|
+
}
|
|
1134
|
+
_handleResize(e) {
|
|
1135
|
+
const state = this._resizeState;
|
|
1136
|
+
if (!state) return;
|
|
1137
|
+
const dx = e.clientX - state.startX;
|
|
1138
|
+
const dy = e.clientY - state.startY;
|
|
1139
|
+
const dir = state.direction;
|
|
1140
|
+
const maxWidth = window.innerWidth;
|
|
1141
|
+
const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
|
|
1142
|
+
let newX = state.startWinX;
|
|
1143
|
+
let newY = state.startWinY;
|
|
1144
|
+
let newW = state.startWidth;
|
|
1145
|
+
let newH = state.startHeight;
|
|
1146
|
+
if (dir.includes("e")) {
|
|
1147
|
+
newW = Math.max(this.minWidth, Math.min(state.startWidth + dx, maxWidth - newX));
|
|
1148
|
+
}
|
|
1149
|
+
if (dir.includes("w")) {
|
|
1150
|
+
const maxDx = state.startWidth - this.minWidth;
|
|
1151
|
+
const actualDx = Math.min(dx, maxDx);
|
|
1152
|
+
newX = Math.max(0, state.startWinX + actualDx);
|
|
1153
|
+
newW = state.startWidth - (newX - state.startWinX);
|
|
1154
|
+
}
|
|
1155
|
+
if (dir.includes("s")) {
|
|
1156
|
+
newH = Math.max(this.minHeight, Math.min(state.startHeight + dy, maxHeight - (newY - TOP_BAR_HEIGHT)));
|
|
1157
|
+
}
|
|
1158
|
+
if (dir.includes("n")) {
|
|
1159
|
+
const maxDy = state.startHeight - this.minHeight;
|
|
1160
|
+
const actualDy = Math.min(dy, maxDy);
|
|
1161
|
+
newY = Math.max(TOP_BAR_HEIGHT, state.startWinY + actualDy);
|
|
1162
|
+
newH = state.startHeight - (newY - state.startWinY);
|
|
1163
|
+
}
|
|
1164
|
+
this.x = newX;
|
|
1165
|
+
this.y = newY;
|
|
1166
|
+
this.width = newW;
|
|
1167
|
+
this.height = newH;
|
|
1168
|
+
this._applyPosition();
|
|
1169
|
+
}
|
|
1170
|
+
// ========== Keyboard Handling ==========
|
|
1171
|
+
_onKeyDown(e) {
|
|
1172
|
+
if (!this.isVisible || this.isMinimized) return;
|
|
1173
|
+
const topWindow = Array.from(_activeWindows.values()).filter((w) => w.isVisible && !w.isMinimized).sort((a, b) => b.zIndex - a.zIndex)[0];
|
|
1174
|
+
if (topWindow !== this) return;
|
|
1175
|
+
if (e.key === "Escape") {
|
|
1176
|
+
this.close();
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (e.key === "Tab" && this.modal && this.element) {
|
|
1180
|
+
const focusables = _collectFocusable(this.element);
|
|
1181
|
+
if (focusables.length === 0) {
|
|
1182
|
+
e.preventDefault();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const first = focusables[0];
|
|
1186
|
+
const last = focusables[focusables.length - 1];
|
|
1187
|
+
const active = document.activeElement;
|
|
1188
|
+
if (e.shiftKey) {
|
|
1189
|
+
if (active === first || !this.element.contains(active)) {
|
|
1190
|
+
e.preventDefault();
|
|
1191
|
+
last.focus();
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
if (active === last || !this.element.contains(active)) {
|
|
1195
|
+
e.preventDefault();
|
|
1196
|
+
first.focus();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// ========== Static Methods ==========
|
|
1202
|
+
/**
|
|
1203
|
+
* Get a window by ID.
|
|
1204
|
+
*/
|
|
1205
|
+
static get(id) {
|
|
1206
|
+
return _activeWindows.get(id) || null;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Every window this class currently holds, newest last.
|
|
1210
|
+
*
|
|
1211
|
+
* `get`/`restore` answer about a window whose id you already have, which is
|
|
1212
|
+
* enough for a consumer reacting to an EVENT — it carries the id. It is not
|
|
1213
|
+
* enough for one that has to repaint from scratch: a taskbar rebuilt with
|
|
1214
|
+
* its pane has missed every event that came before it existed, and the only
|
|
1215
|
+
* honest source for "which windows are minimised right now" is the registry
|
|
1216
|
+
* itself. Returned as an array rather than the live map, so a consumer
|
|
1217
|
+
* iterating it cannot mutate what it is iterating.
|
|
1218
|
+
*/
|
|
1219
|
+
static all() {
|
|
1220
|
+
return [..._activeWindows.values()];
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Restore a minimized window by ID.
|
|
1224
|
+
*/
|
|
1225
|
+
static restore(id) {
|
|
1226
|
+
const win = _activeWindows.get(id);
|
|
1227
|
+
if (win && win.isMinimized) {
|
|
1228
|
+
win._restore();
|
|
1229
|
+
win.bringToFront();
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
export {
|
|
1235
|
+
ManagedWindow
|
|
1236
|
+
};
|
|
1237
|
+
//# sourceMappingURL=chunk-LH5TSOZW.js.map
|